|
34 | 34 | CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg' |
35 | 35 | CMD_RESPOND_UPDATE = 'aibot_respond_update_msg' |
36 | 36 | CMD_SEND_MSG = 'aibot_send_msg' |
| 37 | +# Media upload protocol (3 steps: init -> chunk * N -> finish). The |
| 38 | +# command names below match the convention used by the official aibot |
| 39 | +# Python SDK (``wecom-aibot-sdk-python``). If the WeCom AI Bot rejects |
| 40 | +# them, the SDK logs the server ``errcode`` to aid debugging. |
| 41 | +CMD_UPLOAD_INIT = 'aibot_upload_init' |
| 42 | +CMD_UPLOAD_CHUNK = 'aibot_upload_chunk' |
| 43 | +CMD_UPLOAD_FINISH = 'aibot_upload_finish' |
| 44 | + |
| 45 | +# Default upload chunk size: 512 KB (matches the official SDK). |
| 46 | +_UPLOAD_CHUNK_SIZE = 512 * 1024 |
| 47 | + |
| 48 | +# File-extension to MIME content-type mapping used by ``upload_media``. |
| 49 | +# The WeCom AI Bot uses ``content_type`` in the upload-init body to |
| 50 | +# decide whether the file is an image, voice, video, or generic |
| 51 | +# document. |
| 52 | +_UPLOAD_CONTENT_TYPE_BY_EXT: dict[str, str] = { |
| 53 | + '.png': 'image/png', |
| 54 | + '.jpg': 'image/jpeg', |
| 55 | + '.jpeg': 'image/jpeg', |
| 56 | + '.gif': 'image/gif', |
| 57 | + '.bmp': 'image/bmp', |
| 58 | + '.webp': 'image/webp', |
| 59 | + '.mp3': 'audio/mpeg', |
| 60 | + '.wav': 'audio/wav', |
| 61 | + '.amr': 'audio/amr', |
| 62 | + '.ogg': 'audio/ogg', |
| 63 | + '.mp4': 'video/mp4', |
| 64 | + '.pdf': 'application/pdf', |
| 65 | + '.doc': 'application/msword', |
| 66 | + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', |
| 67 | + '.xls': 'application/vnd.ms-excel', |
| 68 | + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
| 69 | + '.txt': 'text/plain', |
| 70 | + '.md': 'text/markdown', |
| 71 | + '.json': 'application/json', |
| 72 | +} |
| 73 | + |
| 74 | + |
| 75 | +def _guess_content_type(filename: str) -> str: |
| 76 | + """Return the MIME type for *filename* based on its extension. |
| 77 | +
|
| 78 | + Falls back to ``application/octet-stream`` when the extension is |
| 79 | + not recognized, matching the WeCom AI Bot default. |
| 80 | + """ |
| 81 | + import os as _os |
| 82 | + |
| 83 | + if not filename: |
| 84 | + return 'application/octet-stream' |
| 85 | + _, ext = _os.path.splitext(str(filename).lower()) |
| 86 | + return _UPLOAD_CONTENT_TYPE_BY_EXT.get( |
| 87 | + ext, 'application/octet-stream' |
| 88 | + ) |
37 | 89 |
|
38 | 90 |
|
39 | 91 | def _generate_req_id(prefix: str) -> str: |
@@ -258,6 +310,161 @@ async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdow |
258 | 310 | body['text'] = {'content': content} |
259 | 311 | return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG) |
260 | 312 |
|
| 313 | + # ------------------------------------------------------------------ |
| 314 | + # Media upload (image / voice / file) |
| 315 | + # ------------------------------------------------------------------ |
| 316 | + |
| 317 | + async def upload_media( |
| 318 | + self, |
| 319 | + data: bytes, |
| 320 | + filename: str = 'attachment', |
| 321 | + ) -> Optional[dict]: |
| 322 | + """Upload *data* to the WeCom AI Bot CDN and return the parsed ACK. |
| 323 | +
|
| 324 | + Implements the three-step protocol documented for the WeCom |
| 325 | + AI Bot: |
| 326 | +
|
| 327 | + 1. ``aibot_upload_init`` — declare file name, size, content |
| 328 | + type, MD5 and chunk count; receive ``upload_id``. |
| 329 | + 2. ``aibot_upload_chunk`` — send each chunk (base64-encoded |
| 330 | + bytes) until done; receive per-chunk ACK. |
| 331 | + 3. ``aibot_upload_finish`` — finalize the upload; receive |
| 332 | + ``media_id``. |
| 333 | +
|
| 334 | + Returns a dict with the final ``media_id`` (and the raw |
| 335 | + ``finish`` ACK) on success, or ``None`` on any failure. The |
| 336 | + caller is expected to ignore the result and continue |
| 337 | + gracefully — the framework will keep working without media |
| 338 | + delivery. |
| 339 | + """ |
| 340 | + import base64 as _b64 |
| 341 | + import hashlib as _hl |
| 342 | + |
| 343 | + if not data: |
| 344 | + return None |
| 345 | + |
| 346 | + file_size = len(data) |
| 347 | + file_md5 = _hl.md5(data).hexdigest() |
| 348 | + total_chunks = (file_size + _UPLOAD_CHUNK_SIZE - 1) // _UPLOAD_CHUNK_SIZE |
| 349 | + if total_chunks == 0: |
| 350 | + total_chunks = 1 |
| 351 | + content_type = _guess_content_type(filename) |
| 352 | + |
| 353 | + # Step 1: init. |
| 354 | + init_req_id = _generate_req_id(CMD_UPLOAD_INIT) |
| 355 | + init_body = { |
| 356 | + 'file_name': filename, |
| 357 | + 'file_size': file_size, |
| 358 | + 'file_md5': file_md5, |
| 359 | + 'content_type': content_type, |
| 360 | + 'total_chunks': total_chunks, |
| 361 | + } |
| 362 | + init_ack = await self._send_reply( |
| 363 | + init_req_id, |
| 364 | + init_body, |
| 365 | + cmd=CMD_UPLOAD_INIT, |
| 366 | + ) |
| 367 | + if not init_ack or init_ack.get('errcode', 0) != 0: |
| 368 | + await self.logger.warning( |
| 369 | + f'upload_media init failed: ack={init_ack!r}' |
| 370 | + ) |
| 371 | + return None |
| 372 | + upload_id = init_ack.get('upload_id') or init_ack.get('data', {}).get('upload_id') |
| 373 | + if not upload_id: |
| 374 | + await self.logger.warning( |
| 375 | + f'upload_media init returned no upload_id: ack={init_ack!r}' |
| 376 | + ) |
| 377 | + return None |
| 378 | + |
| 379 | + # Step 2: chunks. |
| 380 | + for index in range(total_chunks): |
| 381 | + start = index * _UPLOAD_CHUNK_SIZE |
| 382 | + end = min(start + _UPLOAD_CHUNK_SIZE, file_size) |
| 383 | + chunk_bytes = data[start:end] |
| 384 | + chunk_req_id = _generate_req_id(CMD_UPLOAD_CHUNK) |
| 385 | + chunk_body = { |
| 386 | + 'upload_id': upload_id, |
| 387 | + 'chunk_index': index, |
| 388 | + 'chunk_base64': _b64.b64encode(chunk_bytes).decode('ascii'), |
| 389 | + } |
| 390 | + chunk_ack = await self._send_reply( |
| 391 | + chunk_req_id, |
| 392 | + chunk_body, |
| 393 | + cmd=CMD_UPLOAD_CHUNK, |
| 394 | + ) |
| 395 | + if not chunk_ack or chunk_ack.get('errcode', 0) != 0: |
| 396 | + await self.logger.warning( |
| 397 | + f'upload_media chunk {index} failed: ack={chunk_ack!r}' |
| 398 | + ) |
| 399 | + return None |
| 400 | + |
| 401 | + # Step 3: finish. |
| 402 | + finish_req_id = _generate_req_id(CMD_UPLOAD_FINISH) |
| 403 | + finish_body = {'upload_id': upload_id} |
| 404 | + finish_ack = await self._send_reply( |
| 405 | + finish_req_id, |
| 406 | + finish_body, |
| 407 | + cmd=CMD_UPLOAD_FINISH, |
| 408 | + ) |
| 409 | + if not finish_ack or finish_ack.get('errcode', 0) != 0: |
| 410 | + await self.logger.warning( |
| 411 | + f'upload_media finish failed: ack={finish_ack!r}' |
| 412 | + ) |
| 413 | + return None |
| 414 | + |
| 415 | + media_id = ( |
| 416 | + finish_ack.get('media_id') |
| 417 | + or finish_ack.get('data', {}).get('media_id') |
| 418 | + ) |
| 419 | + if not media_id: |
| 420 | + await self.logger.warning( |
| 421 | + f'upload_media finish returned no media_id: ack={finish_ack!r}' |
| 422 | + ) |
| 423 | + return None |
| 424 | + await self.logger.info( |
| 425 | + f'upload_media OK: filename={filename!r} media_id={media_id!r}' |
| 426 | + ) |
| 427 | + return {'media_id': media_id, 'ack': finish_ack} |
| 428 | + |
| 429 | + async def _reply_media( |
| 430 | + self, |
| 431 | + req_id: str, |
| 432 | + media_id: str, |
| 433 | + kind: str, |
| 434 | + ) -> Optional[dict]: |
| 435 | + """Send a media reply (image / voice / file) referencing *media_id*. |
| 436 | +
|
| 437 | + ``kind`` is one of ``'image'``, ``'voice'``, ``'file'``. Uses |
| 438 | + the standard ``aibot_respond_msg`` command with a per-kind |
| 439 | + body key (matches the convention documented for the WeCom |
| 440 | + AI Bot SDK). |
| 441 | + """ |
| 442 | + if kind not in {'image', 'voice', 'file'}: |
| 443 | + await self.logger.warning( |
| 444 | + f'_reply_media called with unknown kind={kind!r}' |
| 445 | + ) |
| 446 | + return None |
| 447 | + body = { |
| 448 | + 'msgtype': kind, |
| 449 | + kind: {'media_id': media_id}, |
| 450 | + } |
| 451 | + return await self._send_reply(req_id, body, cmd=CMD_RESPOND_MSG) |
| 452 | + |
| 453 | + async def reply_image( |
| 454 | + self, req_id: str, media_id: str |
| 455 | + ) -> Optional[dict]: |
| 456 | + return await self._reply_media(req_id, media_id, 'image') |
| 457 | + |
| 458 | + async def reply_file( |
| 459 | + self, req_id: str, media_id: str |
| 460 | + ) -> Optional[dict]: |
| 461 | + return await self._reply_media(req_id, media_id, 'file') |
| 462 | + |
| 463 | + async def reply_voice( |
| 464 | + self, req_id: str, media_id: str |
| 465 | + ) -> Optional[dict]: |
| 466 | + return await self._reply_media(req_id, media_id, 'voice') |
| 467 | + |
261 | 468 | async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool: |
262 | 469 | """Push a streaming chunk for a given message ID. |
263 | 470 |
|
@@ -293,7 +500,18 @@ async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = Fa |
293 | 500 | await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id) |
294 | 501 | self._stream_last_content[msg_id] = content |
295 | 502 | if is_final: |
296 | | - self._stream_ids.pop(msg_id, None) |
| 503 | + # End-of-round: keep the stream entry but rotate the |
| 504 | + # stream_id so the next LLM round (e.g. after a tool |
| 505 | + # call) opens a *new* stream message. Without this, the |
| 506 | + # stream channel stays mapped to the same stream_id, |
| 507 | + # the next push reuses it, and WeCom — which closes the |
| 508 | + # stream when it sees ``finish=true`` — would never render |
| 509 | + # the next round as its own streaming message (so the |
| 510 | + # chain-of-thought tag would fall back to the markdown |
| 511 | + # path and be shown as raw text instead of a |
| 512 | + # collapsible "thinking" block). |
| 513 | + new_stream_id = _generate_req_id('stream') |
| 514 | + self._stream_ids[msg_id] = f'{req_id}|{new_stream_id}' |
297 | 515 | self._stream_last_content.pop(msg_id, None) |
298 | 516 | self._stream_sessions.pop(msg_id, None) |
299 | 517 | return True |
|
0 commit comments