Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion gateway/platforms/weixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,10 @@ def _extract_text(item_list: List[Dict[str, Any]]) -> str:
if item.get("type") == ITEM_VOICE:
voice_text = str((item.get("voice_item") or {}).get("text") or "")
if voice_text:
return voice_text
# Prefix so the agent can distinguish auto-transcribed voice
# messages from typed text (Weixin's callback delivers them as
# indistinguishable plain strings otherwise).
return f"[voice:transcribed] {voice_text}"
return ""


Expand Down Expand Up @@ -1193,6 +1196,14 @@ def __init__(self, config: PlatformConfig):
self._send_session: Optional[aiohttp.ClientSession] = None
self._poll_task: Optional[asyncio.Task] = None
self._dedup = MessageDeduplicator(ttl_seconds=MESSAGE_DEDUP_TTL_SECONDS)
# Voice echo enforcement: cache last voice transcript per chat_id so the
# outbound send() can guarantee the `> 🎤 你说:"…"` echo prefix even when
# the LLM forgets to add it. One-shot: consumed on next outbound send.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not add a new user-facing environment-variable setting for this behavior. AGENTS.md:102-107 requires non-secret behavioral configuration to live in config.yaml; retain platforms.weixin.extra.voice_echo_enforce, document it, and remove this fallback.

self._pending_voice_echo: Dict[str, str] = {}
_voice_echo_raw = extra.get("voice_echo_enforce")
if _voice_echo_raw is None:
_voice_echo_raw = os.getenv("WEIXIN_VOICE_ECHO_ENFORCE")
self._voice_echo_enforce: bool = _coerce_bool(_voice_echo_raw, default=True)

self._account_id = str(extra.get("account_id") or os.getenv("WEIXIN_ACCOUNT_ID", "")).strip()
self._token = str(config.token or extra.get("token") or os.getenv("WEIXIN_TOKEN", "")).strip()
Expand Down Expand Up @@ -1396,6 +1407,12 @@ async def _process_message(self, message: Dict[str, Any]) -> None:
if self._dedup.is_duplicate(content_key):
logger.debug("[%s] Content-dedup: skipping duplicate message from %s", self.name, sender_id)
return
# Voice echo enforcement: stash transcript keyed on the eventual
# outbound chat_id so send() can guarantee the echo prefix.
if self._voice_echo_enforce and text.startswith("[voice:transcribed] "):
transcript = text[len("[voice:transcribed] "):].strip()
if transcript:
self._pending_voice_echo[sender_id] = transcript

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is keyed by sender_id, but send() looks up by chat_id. For groups, current main derives the outbound/source chat ID from room_id or to_user_id in _guess_chat_type() (gateway/platforms/weixin.py:361-367), so this entry cannot be found. Compute effective_chat_id first and key the pending state with it.


chat_type, effective_chat_id = _guess_chat_type(message, self._account_id)
if chat_type == "group":
Expand Down Expand Up @@ -1674,6 +1691,24 @@ async def send(
) -> SendResult:
if not self._send_session or not self._token:
return SendResult(success=False, error="Not connected")
# Voice echo enforcement (mechanical guarantee, not LLM-dependent):
# if the inbound message for this chat was a voice transcript and the
# outbound reply does NOT already start with the `> 🎤 你说:"…"` echo,
# auto-prepend it. Consumed (popped) so it never bleeds into a later
# turn. Skips media-only sends (empty content after MEDIA: extraction
# would re-trigger on the same transcript next turn — popping here
# is one-shot regardless).
if self._voice_echo_enforce and content and content.strip():
transcript = self._pending_voice_echo.pop(chat_id, None)
if transcript and not content.lstrip().startswith("> 🎤 你说"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This consumes process-local state on the next generic nonblank send() for the chat, not necessarily the response to this inbound voice event. An unrelated proactive send can receive the echo, while no reply leaves state unbounded. Correlate this to the inbound request/reply lifecycle or add bounded expiry and explicit cleanup.

# Escape any embedded double quotes in the transcript so the
# blockquote parses cleanly.
safe = transcript.replace('"', '\\"')
content = f'> 🎤 你说:"{safe}"\n\n{content}'
logger.info(
"[%s] voice-echo enforcer prepended prefix for %s (LLM forgot)",
self.name, _safe_id(chat_id),
)
context_token = self._token_store.get(self._account_id, chat_id)
last_message_id: Optional[str] = None

Expand Down