diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 5157593ac579..e4a1673c4460 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2159,7 +2159,7 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: # Extract MEDIA: tags, allowing optional whitespace after the colon # and quoted/backticked paths for LLM-formatted outputs. media_pattern = re.compile( - r'''[`"']?MEDIA:\s*(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$))[`"']?''' + r'''[`"']?MEDIA:\s*(?P`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa|md|json|yaml|yml|toml|log)(?=[\s`"',;:)\]}]|$))[`"']?''' ) for match in media_pattern.finditer(content): path = match.group("path").strip() diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 1c9fec0af7fb..79165bdf4c3a 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -2145,11 +2145,35 @@ async def send_weixin_direct( adapter._token_store = token_store last_result: Optional[SendResult] = None - cleaned = adapter.format_message(message) - if cleaned: - last_result = await adapter.send(chat_id, cleaned) - if not last_result.success: - return {"error": f"Weixin send failed: {last_result.error}"} + + async def _try_send_with_refresh() -> Optional[SendResult]: + nonlocal last_result + cleaned = adapter.format_message(message) + if cleaned: + last_result = await adapter.send(chat_id, cleaned) + return last_result + + # Retry once on session/rate-limit errors, then give an + # actionable error so cron/send_message can surface it. + for _retry in range(2): + last_result = await _try_send_with_refresh() + if last_result and last_result.success: + break + err = last_result.error if last_result else "" + is_rate = "rate limited" in err or "ret=-2" in err + is_session = "session" in err.lower() + if not is_rate and not is_session: + break + # Clear stale context_token and retry once without it + if context_token: + token_store._cache.pop( + token_store._key(account_id, chat_id), None + ) + context_token = None + await asyncio.sleep(3.0) + + if not last_result or not last_result.success: + return {"error": f"Weixin send failed: {last_result.error if last_result else 'unknown'}"} for media_path, _is_voice in media_files or []: ext = Path(media_path).suffix.lower() diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 23646545bfcd..b47fc0672137 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -347,6 +347,19 @@ def test_as_document_directive_alone_does_not_attach_voice_flag(self): assert media == [("/tmp/x.jpg", False)] # voice flag stays False assert "[[as_document]]" not in cleaned + @pytest.mark.parametrize( + "ext", + ["md", "json", "yaml", "yml", "toml", "log"], + ) + def test_media_tag_accepts_text_config_extensions(self, ext): + """MEDIA: tags must extract text/config artifact paths so callers can + deliver them as file attachments instead of leaving the raw tag in + the platform message body (see issue #32601, bug 1).""" + content = f"MEDIA:/tmp/notes.{ext}" + media, cleaned = BasePlatformAdapter.extract_media(content) + assert media == [(f"/tmp/notes.{ext}", False)] + assert "MEDIA:" not in cleaned + def test_both_directives_can_coexist(self): """A response could (rarely) contain both [[audio_as_voice]] for an ogg file AND [[as_document]] for an attached image. The voice flag