Files
hermes-agent/gateway/platforms/weixin.py
Teknium 9bcbe7b5df feat(i18n): pluggable, layered language packs across core, Desktop and TUI (#126296)
* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales

* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter

ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.

* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs

* chore(tui): split en catalog siblings by lane (slash sibling)

* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports

* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts

* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list

* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes

* feat(plugins): report language-pack layers in the mid-run activation summary

* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n

StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.

* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter

- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
  the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
  layers partial packs (nested or flat dotted) over bundled/en via
  mergeTranslations; a string over a function-valued en entry becomes a
  positional {0}/{1} formatter; $appLocaleVersion bumps so translators
  re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
  registry as source 'backend' (method-not-found is silent); re-synced on
  socket open, display.language change and profile switch. A saved pack-only
  language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
  tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
  from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).

* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)

Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.

* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()

- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
  ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
  accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
  properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces

* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)

* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()

Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.

locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).

* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()

- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions

* i18n(platforms): route Google Chat and Teams user-facing text through t()

Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.

Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.

* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()

LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.

* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)

Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.

* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)

_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.

* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)

RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.

* i18n(cli): live-work dock, subagent monitor and render copy through t()

cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.

* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()

* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)

get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.

* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output

* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time

Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).

* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)

cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.

* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()

- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
  at call time; category labels via slash.category.*; help/alias/usage suffixes
  via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
  bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
  lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
  status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
  approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
  bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
  heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
  sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
  keeps its English constant; callers use history_unreadable() ->
  gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.

* i18n(telegram): route adapter chat copy through t()

Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.

Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.

* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()

- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor

* i18n(discord): route adapter chat copy through t()

Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().

* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)

* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()

- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
  existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
  via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
  _DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
  Updating/Generating) are one full template per variant; plurals use
  <key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
  translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).

* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English

* test(i18n): pin Telegram/Discord adapter catalog wiring

Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.

* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge

* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint

* i18n(tr): translate bundled catalog + tui pack

* i18n(ja): translate bundled catalog + tui pack

* i18n(ko): translate bundled catalog + tui pack

* i18n(zh): translate bundled catalog + tui pack

* i18n(fr): translate bundled catalog + tui pack

* i18n(af): translate bundled catalog + tui pack

* i18n(uk): translate bundled catalog + tui pack

* i18n(ar): translate bundled catalog + tui pack

* i18n(pt): translate bundled catalog + tui pack

* i18n(it): translate bundled catalog + tui pack

* i18n(es): translate bundled catalog + tui pack

* i18n(zh-hant): translate bundled catalog + tui pack

* i18n(ru): translate bundled catalog + tui pack

* i18n(hu): translate bundled catalog + tui pack

* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)

* i18n(de): translate bundled catalog + tui pack

* i18n(ga): translate bundled catalog + tui pack

* test(i18n): fixture matches _normalize_lang(lang, home) signature

* i18n(tui): scaffold userMessages/slashCmd en siblings

* i18n(tui): wire secure prompts + content tables

* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays

Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.

* i18n(tui): wire slash ops/wake replies

* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)

* i18n(tui): wire slash core/debug/setup replies

* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)

* i18n(tui): wire slash session/topup/subscription replies

* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)

* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys

* i18n(tui): wire userMessages copy through the userMessages namespace

* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json

* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)

Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).

* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)

* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)

* tui: i18n-export-en script (English templates for pack translators)

* docs(i18n): bundled TUI packs are the bottom layer of the tui surface

* i18n(ru): translate TUI pack

* i18n(ar): translate TUI pack

* i18n(es): translate TUI pack

* i18n(pt): translate TUI pack

* i18n(ko): translate TUI pack

* i18n(de): translate TUI pack

* i18n(ja): translate TUI pack

* i18n(fr): translate TUI pack

* i18n(tr): translate TUI pack

* i18n(it): translate TUI pack

* i18n(zh): translate TUI pack

* i18n(zh-hant): translate TUI pack

* i18n(hu): translate TUI pack

* i18n(uk): translate TUI pack

1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.

Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).

* i18n(ga): translate TUI pack

* i18n(af): translate TUI pack

* plugin_guard: locale catalogs in language packs step down the agent-config family

A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.

* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)

* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header

Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.

* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel

- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
  and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
  agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)

* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})

* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property

* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal

* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export

The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.

* test: unbreak two main-red timing tests the PR merge-ref inherits

- test_local_runtime racing fake publishes the modern state record (legacy pid-only
  records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
  a fixed 0.35s, which a loaded CI runner does not always meet

* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)

* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak

SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.

* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings

* chore(i18n): regenerate desktop key catalog after main sync

---------

Co-authored-by: Teknium <teknium@nousresearch.com>
2026-09-28 14:16:18 -07:00

1244 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Weixin (WeChat personal account) adapter over Tencent's iLink Bot API. Long-poll ``getupdates`` drives inbound
delivery; every outbound reply must echo the peer's latest ``context_token``; media moves through an AES-128-ECB
encrypted CDN; ``qr_login`` backs the gateway setup wizard."""
from __future__ import annotations
import asyncio, base64, contextlib, hashlib, json, logging, mimetypes, os, re, secrets, tempfile, textwrap, time, uuid # noqa: E401
from datetime import datetime
from functools import partial
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
from urllib.parse import quote, urlparse
logger = logging.getLogger(__name__)
WEIXIN_COPY_LINE_WIDTH = 120
try:
import aiohttp
except ImportError: # pragma: no cover - dependency gate
aiohttp = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = aiohttp is not None
try:
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
except ImportError: # pragma: no cover - dependency gate
default_backend = Cipher = algorithms = modes = None # type: ignore[assignment]
CRYPTO_AVAILABLE = Cipher is not None
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator, cancel_task, greedy_pack_blocks
from gateway.platforms.access_policy_mixin import OwnAccessPolicyMixin
from agent.i18n import t
from gateway.platforms.base import (
_IMAGE_EXTS, _VIDEO_EXTS, gateway_trust_env, BasePlatformAdapter, SendResult,
cache_audio_from_bytes_async, cache_document_from_bytes_async, cache_image_from_bytes_async,
)
from gateway.platforms.event import MessageEvent, MessageType
from hermes_constants import get_hermes_home
from utils import atomic_json_write
from gateway.platforms._shared import extra_or_secret as _extra_or_env, get_scoped_secret as _wx_secret
def _extra_or_secret(extra: Dict[str, Any], key: str, default: str = "") -> str:
"""``config.extra[key]`` first, else the scoped ``WEIXIN_<KEY>``; stripped."""
return str(_extra_or_env(extra, key, f"WEIXIN_{key.upper()}", default)).strip()
ILINK_BASE_URL = "https://ilinkai.weixin.qq.com"
WEIXIN_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
ILINK_APP_ID, CHANNEL_VERSION, ILINK_APP_CLIENT_VERSION = "bot", "2.2.0", (2 << 16) | (2 << 8) | 0
EP_GET_UPDATES, EP_SEND_MESSAGE, EP_SEND_TYPING = "ilink/bot/getupdates", "ilink/bot/sendmessage", "ilink/bot/sendtyping"
EP_GET_CONFIG, EP_GET_UPLOAD_URL = "ilink/bot/getconfig", "ilink/bot/getuploadurl"
EP_GET_BOT_QR, EP_GET_QR_STATUS = "ilink/bot/get_bot_qrcode", "ilink/bot/get_qrcode_status"
LONG_POLL_TIMEOUT_MS, API_TIMEOUT_MS, CONFIG_TIMEOUT_MS, QR_TIMEOUT_MS = 35_000, 15_000, 10_000, 35_000
MAX_CONSECUTIVE_FAILURES, RETRY_DELAY_SECONDS, BACKOFF_DELAY_SECONDS = 3, 2, 30
SESSION_EXPIRED_ERRCODE, RATE_LIMIT_ERRCODE = -14, -2 # -2: iLink frequency limit — backoff and retry
MESSAGE_DEDUP_TTL_SECONDS = 300
MEDIA_IMAGE, MEDIA_VIDEO, MEDIA_FILE, MEDIA_VOICE = 1, 2, 3, 4 # getuploadurl media_type
ITEM_TEXT, ITEM_IMAGE, ITEM_VOICE, ITEM_FILE, ITEM_VIDEO = 1, 2, 3, 4, 5 # item_list entry types
MSG_TYPE_BOT, MSG_STATE_FINISH = 2, 2
TYPING_START, TYPING_STOP = 1, 2
_LIVE_ADAPTERS: Dict[str, Any] = {}
_HEADER_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
_TABLE_RULE_RE = re.compile(r"^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$")
_FENCE_RE = re.compile(r"^```([^\n`]*)\s*$")
def _is_stale_session_ret(ret: "Optional[int]", errcode: "Optional[int]", errmsg: "Optional[str]") -> bool:
"""Recognize stale-session variants of iLink's ``-2`` response, not real rate limits."""
return (ret == RATE_LIMIT_ERRCODE or errcode == RATE_LIMIT_ERRCODE) and (errmsg or "").lower() in {
"unknown error",
"prepare failed",
}
def _is_session_expired(resp: Dict[str, Any], ret: Any, errcode: Any) -> bool:
return SESSION_EXPIRED_ERRCODE in (ret, errcode) or _is_stale_session_ret(ret, errcode, resp.get("errmsg") or resp.get("msg"))
def _session_not_ready_error(ret: Any, errcode: Any, errmsg: Any) -> RuntimeError:
"""The stale-session ``-2`` after the tokenless re-send is exhausted (or with no token to drop): iLink will not
prepare a bot-initiated send until this peer messages the bot again. Deterministic, so it is neither retried nor
fed to the rate-limit breaker (#80125). The text must not contain "rate limit" — ``classify_send_error`` would
route it back into the rate-limited redelivery lane."""
return RuntimeError(
f"iLink sendmessage session not ready: ret={ret} errcode={errcode} errmsg={errmsg or 'unknown error'}"
" — the user must send the bot a message first (or re-pair)")
def _make_ssl_connector() -> Optional["aiohttp.TCPConnector"]:
"""TCPConnector with certifi's CA bundle (``ilinkai.weixin.qq.com`` fails some system stores, e.g. Homebrew
OpenSSL); None without certifi so aiohttp's default (honors ``SSL_CERT_FILE`` under trust_env) applies.
``keepalive_timeout=2`` + ``enable_cleanup_closed`` drain idle CLOSE_WAIT sockets behind proxies like Warp.
Uses a tight ``keepalive_timeout=2`` (default aiohttp: 30s) so idle connections drain promptly behind
proxies like Cloudflare Warp that leave peer-initiated FIN in ``CLOSE_WAIT`` (same class as #18451).
``enable_cleanup_closed=True`` helps the connector clean up sockets that the remote side has already
closed.
"""
try:
import ssl
import certifi
except ImportError:
return None
if not AIOHTTP_AVAILABLE:
return None
return aiohttp.TCPConnector(ssl=ssl.create_default_context(cafile=certifi.where()), keepalive_timeout=2, enable_cleanup_closed=True)
def _new_session(**kwargs: Any) -> "aiohttp.ClientSession":
return aiohttp.ClientSession(trust_env=gateway_trust_env(), connector=_make_ssl_connector(), **kwargs)
def check_weixin_requirements() -> bool:
return AIOHTTP_AVAILABLE and CRYPTO_AVAILABLE
def _safe_id(value: Optional[str], keep: int = 8) -> str:
raw = str(value or "").strip()
return raw[:keep] if raw else "?"
def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
pad_len = block_size - (len(data) % block_size)
return data + bytes([pad_len] * pad_len)
def _aes128_ecb_encrypt(plaintext: bytes, key: bytes) -> bytes:
encryptor = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend()).encryptor()
return encryptor.update(_pkcs7_pad(plaintext)) + encryptor.finalize()
def _aes128_ecb_decrypt(ciphertext: bytes, key: bytes) -> bytes:
"""Decrypt and strip PKCS#7 padding when it is well-formed (else return the raw block output)."""
decryptor = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend()).decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
pad_len = padded[-1] if padded else 0
if 1 <= pad_len <= 16 and padded.endswith(bytes([pad_len]) * pad_len):
return padded[:-pad_len]
return padded
def _headers(token: Optional[str], body: str) -> Dict[str, str]:
uin = base64.b64encode(str(int.from_bytes(secrets.token_bytes(4), "big")).encode("utf-8")).decode("ascii")
return {
"Content-Type": "application/json", "AuthorizationType": "ilink_bot_token",
"Content-Length": str(len(body.encode("utf-8"))), "X-WECHAT-UIN": uin,
"iLink-App-Id": ILINK_APP_ID, "iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
**({"Authorization": f"Bearer {token}"} if token else {}),
}
def _account_dir(hermes_home: str) -> Path:
path = Path(hermes_home) / "weixin" / "accounts"
path.mkdir(parents=True, exist_ok=True)
return path
def _read_json(path: Path) -> Any:
try:
# utf-8-sig (ours): tolerate BOM-persisted JSON files.
return json.loads(path.read_text(encoding="utf-8-sig")) if path.exists() else None
except Exception:
return None
def save_weixin_account(hermes_home: str, *, account_id: str, token: str, base_url: str, user_id: str = "") -> None:
path = _account_dir(hermes_home) / f"{account_id}.json"
saved_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
atomic_json_write(path, {"token": token, "base_url": base_url, "user_id": user_id, "saved_at": saved_at})
with contextlib.suppress(OSError):
path.chmod(0o600)
def load_weixin_account(hermes_home: str, account_id: str) -> Optional[Dict[str, Any]]:
return _read_json(_account_dir(hermes_home) / f"{account_id}.json")
class ContextTokenStore:
"""Disk-backed ``context_token`` cache keyed by account + peer."""
def __init__(self, hermes_home: str):
self._root = _account_dir(hermes_home)
self._cache: Dict[str, str] = {}
# Serializes the offloaded flushes so two concurrent set() calls
# cannot land their writes out of order (last-writer-wins would drop
# the newer token from disk).
self._persist_lock = asyncio.Lock()
@staticmethod
def _key(account_id: str, user_id: str) -> str:
return f"{account_id}:{user_id}"
def restore(self, account_id: str) -> None:
path = self._root / f"{account_id}.context-tokens.json"
if not path.exists():
return
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except Exception as exc:
logger.warning("weixin: failed to restore context tokens for %s: %s", _safe_id(account_id), exc)
return
restored = {self._key(account_id, u): t for u, t in data.items() if isinstance(t, str) and t}
self._cache.update(restored)
if restored:
logger.info("weixin: restored %d context token(s) for %s", len(restored), _safe_id(account_id))
def get(self, account_id: str, user_id: str) -> Optional[str]:
return self._cache.get(self._key(account_id, user_id))
async def set(self, account_id: str, user_id: str, token: str) -> None:
self._cache[self._key(account_id, user_id)] = token
# atomic_json_write() fsyncs, so the flush is offloaded off the loop; the payload is snapshotted
# here (the worker never iterates ``_cache`` mid-mutation) and the lock keeps flushes in order.
async with self._persist_lock:
prefix = f"{account_id}:"
payload = {key[len(prefix):]: value for key, value in self._cache.items() if key.startswith(prefix)}
await asyncio.to_thread(self._persist, account_id, payload)
def _persist(self, account_id: str, payload: Dict[str, str]) -> None:
try:
atomic_json_write(self._root / f"{account_id}.context-tokens.json", payload)
except Exception as exc:
logger.warning("weixin: failed to persist context tokens for %s: %s", _safe_id(account_id), exc)
class TypingTicketCache:
"""Short-lived typing ticket cache from ``getconfig``."""
def __init__(self, ttl_seconds: float = 600.0):
self._ttl_seconds = ttl_seconds
self._cache: Dict[str, Tuple[str, float]] = {}
def get(self, user_id: str) -> Optional[str]:
entry = self._cache.get(user_id)
if entry and time.time() - entry[1] < self._ttl_seconds:
return entry[0]
self._cache.pop(user_id, None)
return None
def set(self, user_id: str, ticket: str) -> None:
self._cache[user_id] = (ticket, time.time())
def _parse_aes_key(aes_key_b64: str) -> bytes:
decoded = base64.b64decode(aes_key_b64)
if len(decoded) == 16:
return decoded
text = decoded.decode("ascii", errors="ignore") if len(decoded) == 32 else ""
if text and all(ch in "0123456789abcdefABCDEF" for ch in text):
return bytes.fromhex(text)
raise ValueError(f"unexpected aes_key format ({len(decoded)} decoded bytes)")
def _guess_chat_type(message: Dict[str, Any], account_id: str) -> Tuple[str, str]:
room_id = str(message.get("room_id") or message.get("chat_room_id") or "").strip()
to_user_id = str(message.get("to_user_id") or "").strip()
if room_id or (to_user_id and account_id and to_user_id != account_id and message.get("msg_type") == 1):
return "group", room_id or to_user_id or str(message.get("from_user_id") or "")
return "dm", str(message.get("from_user_id") or "")
# HTTP helpers enforce timeouts via asyncio.wait_for(), not aiohttp ClientTimeout, which raises
# "Timeout context manager should be used inside a task" under run_coroutine_threadsafe() from cron.
async def _api_request(
session: "aiohttp.ClientSession", method: str, *, base_url: str, endpoint: str, headers: Dict[str, str], timeout_ms: int, body: Optional[str] = None,
) -> Dict[str, Any]:
async def _do() -> Dict[str, Any]:
kwargs = {"data": body} if body is not None else {}
async with getattr(session, method.lower())(f"{base_url.rstrip('/')}/{endpoint}", headers=headers, **kwargs) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink {method} {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
async def _api_post(
session: "aiohttp.ClientSession", *, base_url: str, endpoint: str, payload: Dict[str, Any], token: Optional[str], timeout_ms: int,
) -> Dict[str, Any]:
body = json.dumps({**payload, "base_info": {"channel_version": CHANNEL_VERSION}}, ensure_ascii=False, separators=(",", ":"))
return await _api_request(session, "POST", base_url=base_url, endpoint=endpoint, headers=_headers(token, body), timeout_ms=timeout_ms, body=body)
async def _api_get(session: "aiohttp.ClientSession", *, base_url: str, endpoint: str, timeout_ms: int) -> Dict[str, Any]:
headers = {"iLink-App-Id": ILINK_APP_ID, "iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION)}
return await _api_request(session, "GET", base_url=base_url, endpoint=endpoint, headers=headers, timeout_ms=timeout_ms)
async def _get_updates(session: "aiohttp.ClientSession", *, base_url: str, token: str, sync_buf: str, timeout_ms: int) -> Dict[str, Any]:
try:
return await _api_post(session, base_url=base_url, endpoint=EP_GET_UPDATES, payload={"get_updates_buf": sync_buf}, token=token, timeout_ms=timeout_ms)
except asyncio.TimeoutError:
return {"ret": 0, "msgs": [], "get_updates_buf": sync_buf}
async def _send_items(
session: "aiohttp.ClientSession", *, base_url: str, token: str, to: str, item_list: List[Dict[str, Any]], context_token: Optional[str], client_id: str,
) -> Dict[str, Any]:
message: Dict[str, Any] = {
"from_user_id": "", "to_user_id": to, "client_id": client_id, "message_type": MSG_TYPE_BOT, "message_state": MSG_STATE_FINISH,
"item_list": item_list}
if context_token:
message["context_token"] = context_token
return await _api_post(session, base_url=base_url, endpoint=EP_SEND_MESSAGE, payload={"msg": message}, token=token, timeout_ms=API_TIMEOUT_MS)
async def _send_message(
session: "aiohttp.ClientSession", *, base_url: str, token: str, to: str, text: str, context_token: Optional[str], client_id: str,
) -> Dict[str, Any]:
if not text or not text.strip():
raise ValueError("_send_message: text must not be empty")
item_list = [{"type": ITEM_TEXT, "text_item": {"text": text}}]
return await _send_items(session, base_url=base_url, token=token, to=to, item_list=item_list, context_token=context_token, client_id=client_id)
async def _get_config(session: "aiohttp.ClientSession", *, base_url: str, token: str, user_id: str, context_token: Optional[str]) -> Dict[str, Any]:
payload: Dict[str, Any] = {"ilink_user_id": user_id}
if context_token:
payload["context_token"] = context_token
return await _api_post(session, base_url=base_url, endpoint=EP_GET_CONFIG, payload=payload, token=token, timeout_ms=CONFIG_TIMEOUT_MS)
async def _get_upload_url(
session: "aiohttp.ClientSession", *, base_url: str, token: str, to_user_id: str, media_type: int, filekey: str, rawsize: int,
rawfilemd5: str, filesize: int, aeskey_hex: str,
) -> Dict[str, Any]:
payload = {
"filekey": filekey, "media_type": media_type, "to_user_id": to_user_id, "rawsize": rawsize, "rawfilemd5": rawfilemd5,
"filesize": filesize, "no_need_thumb": True, "aeskey": aeskey_hex}
return await _api_post(session, base_url=base_url, endpoint=EP_GET_UPLOAD_URL, payload=payload, token=token, timeout_ms=API_TIMEOUT_MS)
async def _upload_ciphertext(session: "aiohttp.ClientSession", *, ciphertext: bytes, upload_url: str) -> str:
async def _do() -> str:
async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}) as response:
encrypted_param = response.headers.get("x-encrypted-param") if response.status == 200 else None
if encrypted_param:
await response.read()
return encrypted_param
raw = (await response.text())[:200]
raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw}" if response.status == 200 else f"CDN upload HTTP {response.status}: {raw}")
return await asyncio.wait_for(_do(), timeout=120)
async def _download_bytes(session: "aiohttp.ClientSession", *, url: str, timeout_seconds: float = 60.0) -> bytes:
async def _do() -> bytes:
async with session.get(url) as response:
response.raise_for_status()
return await response.read()
return await asyncio.wait_for(_do(), timeout=timeout_seconds)
_WEIXIN_CDN_ALLOWLIST: frozenset[str] = frozenset({
"novac2c.cdn.weixin.qq.com", "ilinkai.weixin.qq.com", "wx.qlogo.cn", "thirdwx.qlogo.cn", "res.wx.qq.com", "mmbiz.qpic.cn", "mmbiz.qlogo.cn"})
def _assert_weixin_cdn_url(url: str) -> None:
try:
parsed = urlparse(url)
scheme, host = parsed.scheme.lower(), parsed.hostname or ""
except Exception as exc: # noqa: BLE001
raise ValueError(f"Unparseable media URL: {url!r}") from exc
if scheme not in {"http", "https"}:
raise ValueError(f"Media URL has disallowed scheme {scheme!r}; only http/https are permitted.")
if host not in _WEIXIN_CDN_ALLOWLIST:
raise ValueError(f"Media URL host {host!r} is not in the WeChat CDN allowlist. Refusing to fetch to prevent SSRF.")
async def _download_and_decrypt_media(
session: "aiohttp.ClientSession", *, cdn_base_url: str, encrypted_query_param: Optional[str], aes_key_b64: Optional[str],
full_url: Optional[str], timeout_seconds: float,
) -> bytes:
if encrypted_query_param:
url = f"{cdn_base_url.rstrip('/')}/download?encrypted_query_param={quote(encrypted_query_param, safe='')}"
elif full_url:
_assert_weixin_cdn_url(full_url)
url = full_url
else:
raise RuntimeError("media item had neither encrypt_query_param nor full_url")
raw = await _download_bytes(session, url=url, timeout_seconds=timeout_seconds)
return _aes128_ecb_decrypt(raw, _parse_aes_key(aes_key_b64)) if aes_key_b64 else raw
def _walk_markdown_lines(content: str):
"""Yield ``(rstripped line, is_fence, in_code_block)`` per line; ``in_code_block`` is the state *before* a fence toggles it."""
in_code_block = False
for raw_line in content.splitlines():
line = raw_line.rstrip()
is_fence = bool(_FENCE_RE.match(line.strip()))
yield line, is_fence, in_code_block
if is_fence:
in_code_block = not in_code_block
def _normalize_markdown_blocks(content: str) -> str:
"""rstrip lines and collapse blank runs to one, leaving fenced code untouched."""
result: List[str] = []
blank_run = 0
for line, is_fence, in_code_block in _walk_markdown_lines(content):
if not is_fence and not in_code_block and not line.strip():
blank_run += 1
if blank_run <= 1:
result.append("")
continue
if is_fence or not in_code_block:
blank_run = 0
result.append(line)
return "\n".join(result).strip()
def _wrap_copy_friendly_lines_for_weixin(content: str) -> str:
"""Wrap long display lines that are hard to copy in WeChat clients (not code/table lines)."""
if not content:
return content
wrapped: List[str] = []
for line, is_fence, in_code_block in _walk_markdown_lines(content):
stripped = line.strip()
keep = is_fence or in_code_block or len(line) <= WEIXIN_COPY_LINE_WIDTH or not stripped or stripped.startswith("|")
if keep or _TABLE_RULE_RE.match(stripped):
wrapped.append(line)
else:
wrapped.extend(textwrap.wrap(
line, width=WEIXIN_COPY_LINE_WIDTH, break_long_words=False, break_on_hyphens=False, replace_whitespace=False,
drop_whitespace=True) or [line])
return "\n".join(wrapped).strip()
def _split_markdown_blocks(content: str) -> List[str]:
blocks: List[str] = []
current: List[str] = []
def flush() -> None:
if current:
blocks.append("\n".join(current).strip())
current.clear()
for line, is_fence, in_code_block in _walk_markdown_lines(content):
if is_fence and not in_code_block: # opening fence starts a fresh block
flush()
if is_fence or in_code_block or line.strip():
current.append(line)
else:
flush()
if is_fence and in_code_block: # closing fence ends the block
flush()
flush()
return [block for block in blocks if block]
def _split_delivery_units_for_weixin(content: str) -> List[str]:
"""Top-level lines become units; fenced code stays intact; indented continuation lines attach to the previous line."""
units: List[str] = []
for block in _split_markdown_blocks(content):
if _FENCE_RE.match(block.splitlines()[0].strip()):
units.append(block)
continue
current: List[str] = []
for raw_line in block.splitlines():
line = raw_line.rstrip()
if current and line.strip() and raw_line.startswith((" ", "\t")):
current.append(line)
continue
units.append("\n".join(current).strip())
current = [line] if line.strip() else []
units.append("\n".join(current).strip())
return [unit for unit in units if unit]
def _looks_like_chatty_line_for_weixin(line: str) -> bool:
stripped = line.strip()
return bool(
stripped and len(stripped) <= 48 and not line.startswith((" ", "\t")) and not stripped.startswith((">", "-", "*", "【", "#", "|"))
and not _TABLE_RULE_RE.match(stripped) and not re.match(r"^\*\*[^*]+\*\*$", stripped) and not re.match(r"^\d+\.\s", stripped))
def _should_split_short_chat_block_for_weixin(block: str) -> bool:
"""Split only chat-like multiline blocks (2-6 chatty lines, first line not a heading) into separate bubbles."""
lines = [line for line in block.splitlines() if line.strip()]
if not 2 <= len(lines) <= 6:
return False
first = lines[0].strip()
if _HEADER_RE.match(first) or (len(first) <= 24 and first.endswith((":", ":"))):
return False
return all(_looks_like_chatty_line_for_weixin(line) for line in lines)
def _pack_markdown_blocks_for_weixin(content: str, max_length: int) -> List[str]:
if len(content) <= max_length:
return [content]
# Block extraction stays weixin-local (anchored _FENCE_RE + per-line rstrip); packing is shared.
overflow = lambda block: BasePlatformAdapter.truncate_message(block, max_length) # noqa: E731
return greedy_pack_blocks(_split_markdown_blocks(content), max_length, overflow=overflow)
def _split_text_for_weixin_delivery(content: str, max_length: int, split_per_line: bool = False) -> List[str]:
"""Compact (default): one message when it fits, unless it reads as a short chatty exchange (separate
bubbles). Per-line (legacy ``extra.split_multiline_messages`` / ``WEIXIN_SPLIT_MULTILINE_MESSAGES``):
top-level line breaks become separate messages. Oversized units use block-aware packing."""
if not content:
return []
if split_per_line:
if len(content) <= max_length and "\n" not in content:
return [content]
units = _split_delivery_units_for_weixin(content)
chunks = [c for u in units for c in ([u] if len(u) <= max_length else _pack_markdown_blocks_for_weixin(u, max_length))]
return [c for c in chunks if c] or [content]
if len(content) > max_length:
return _pack_markdown_blocks_for_weixin(content, max_length) or [content]
return _split_delivery_units_for_weixin(content) if _should_split_short_chat_block_for_weixin(content) else [content]
def _coerce_bool(value: Any, default: bool = True) -> bool:
"""Coerce a config value to bool, tolerating strings like ``"true"``; unknown -> ``default``."""
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
return True if text in {"1", "true", "yes", "on"} else False if text in {"0", "false", "no", "off"} else default
def _extract_text(item_list: List[Dict[str, Any]]) -> str:
for item in item_list:
if item.get("type") == ITEM_TEXT:
text = str((item.get("text_item") or {}).get("text") or "")
ref = item.get("ref_msg") or {}
ref_item = ref.get("message_item") or {}
if ref_item.get("type") in {ITEM_IMAGE, ITEM_VIDEO, ITEM_FILE, ITEM_VOICE}:
title = ref.get("title") or ""
return f"[引用媒体: {title}]\n{text}".strip() if title else f"[引用媒体]\n{text}".strip()
if ref_item:
parts = [p for p in (str(ref["title"]) if ref.get("title") else "", _extract_text([ref_item])) if p]
if parts:
return f"[引用: {' | '.join(parts)}]\n{text}".strip()
return text
for item in item_list:
if item.get("type") == ITEM_VOICE:
# Tencent's ``voice_item.text`` is their STT output and is wrong for non-Chinese audio.
# When raw audio exists return "" so gateway/run.py's central STT transcribes the download;
# otherwise use Weixin's transcript but mark its voice origin.
# #27300: Tencent Cloud's `voice_item.text` is their STT output, which is wrong for any
# non-Chinese audio (the original report was a Russian voice message that came back as English
# gibberish). Return empty so the central STT pipeline in ``gateway/run.py`` produces the body
# from the downloaded audio instead.
voice_item = item.get("voice_item") or {}
# Use it, but preserve the voice origin so the agent can distinguish this from text the user
# typed (#65022).
voice_text = str(voice_item.get("text") or "")
if not (voice_item.get("media") or {}) and voice_text:
return f"[Voice transcription provided by Weixin]\n{voice_text}"
return ""
_MIME_PREFIX_TYPES = (("image/", MessageType.PHOTO), ("video/", MessageType.VIDEO), ("audio/", MessageType.VOICE))
def _message_type_from_media(media_types: List[str], text: str) -> MessageType:
for prefix, message_type in _MIME_PREFIX_TYPES:
if any(m.startswith(prefix) for m in media_types):
return message_type
return MessageType.DOCUMENT if media_types else MessageType.COMMAND if text.startswith("/") else MessageType.TEXT
def _load_sync_buf(hermes_home: str, account_id: str) -> str:
data = _read_json(_account_dir(hermes_home) / f"{account_id}.sync.json")
return data.get("get_updates_buf", "") if isinstance(data, dict) else ""
def _save_sync_buf(hermes_home: str, account_id: str, sync_buf: str) -> None:
atomic_json_write(_account_dir(hermes_home) / f"{account_id}.sync.json", {"get_updates_buf": sync_buf})
async def _fetch_qr(session: "aiohttp.ClientSession", bot_type: str) -> Tuple[str, str]:
qr_resp = await _api_get(session, base_url=ILINK_BASE_URL, endpoint=f"{EP_GET_BOT_QR}?bot_type={bot_type}", timeout_ms=QR_TIMEOUT_MS)
return str(qr_resp.get("qrcode") or ""), str(qr_resp.get("qrcode_img_content") or "")
def _print_qr(qrcode_value: str, qrcode_url: str, *, report_render_error: bool) -> None:
"""Print the QR URL + ASCII render; WeChat must scan the liteapp URL (``qrcode_img_content``), not the bare hex token."""
if qrcode_url:
print(qrcode_url)
try:
import qrcode
qr = qrcode.QRCode()
qr.add_data(qrcode_url or qrcode_value)
qr.make(fit=True)
qr.print_ascii(invert=True)
except Exception as _qr_exc:
if report_render_error:
print(f"(终端二维码渲染失败: {_qr_exc},请直接打开上面的二维码链接)")
async def qr_login(hermes_home: str, *, bot_type: str = "3", timeout_seconds: int = 480) -> Optional[Dict[str, str]]:
if not AIOHTTP_AVAILABLE:
raise RuntimeError("aiohttp is required for Weixin QR login")
async with _new_session() as session:
try:
qrcode_value, qrcode_url = await _fetch_qr(session, bot_type)
except Exception as exc:
logger.error("weixin: failed to fetch QR code: %s", exc)
return None
if not qrcode_value:
logger.error("weixin: QR response missing qrcode")
return None
print("\n请使用微信扫描以下二维码:")
_print_qr(qrcode_value, qrcode_url, report_render_error=True)
deadline = time.monotonic() + timeout_seconds
current_base_url, refresh_count = ILINK_BASE_URL, 0
while time.monotonic() < deadline:
try:
status_resp = await _api_get(session, base_url=current_base_url, endpoint=f"{EP_GET_QR_STATUS}?qrcode={qrcode_value}", timeout_ms=QR_TIMEOUT_MS)
except Exception as exc:
if not isinstance(exc, asyncio.TimeoutError):
logger.warning("weixin: QR poll error: %s", exc)
await asyncio.sleep(1)
continue
status = str(status_resp.get("status") or "wait")
if status == "wait":
print(".", end="", flush=True)
elif status == "scaned":
print("\n已扫码,请在微信里确认...")
elif status == "scaned_but_redirect" and status_resp.get("redirect_host"):
current_base_url = f"https://{status_resp['redirect_host']}"
elif status == "expired":
refresh_count += 1
if refresh_count > 3:
print("\n二维码多次过期,请重新执行登录。")
return None
print(f"\n二维码已过期,正在刷新... ({refresh_count}/3)")
try:
qrcode_value, qrcode_url = await _fetch_qr(session, bot_type)
_print_qr(qrcode_value, qrcode_url, report_render_error=False)
except Exception as exc:
logger.error("weixin: QR refresh failed: %s", exc)
return None
elif status == "confirmed":
creds = {
"account_id": str(status_resp.get("ilink_bot_id") or ""), "token": str(status_resp.get("bot_token") or ""),
"base_url": str(status_resp.get("baseurl") or ILINK_BASE_URL), "user_id": str(status_resp.get("ilink_user_id") or "")}
if not creds["account_id"] or not creds["token"]:
logger.error("weixin: QR confirmed but credential payload was incomplete")
return None
save_weixin_account(hermes_home, **creds)
print(f"\n微信连接成功,account_id={creds['account_id']}")
return creds
await asyncio.sleep(1)
print("\n微信登录超时。")
return None
# Outbound item shapes: item type -> (item key, extra fields after the shared ``media`` block).
_ITEM_SHAPES: Dict[int, Tuple[str, Callable[[Dict[str, Any]], Dict[str, Any]]]] = {
ITEM_FILE: ("file_item", lambda kw: {"file_name": kw["filename"], "len": str(kw["plaintext_size"])}),
ITEM_IMAGE: ("image_item", lambda kw: {"mid_size": kw["ciphertext_size"]}),
ITEM_VIDEO: ("video_item", lambda kw: {
"video_size": kw["ciphertext_size"], "play_length": kw.get("play_length", 0), "video_md5": kw.get("rawfilemd5", "")}),
ITEM_VOICE: ("voice_item", lambda kw: {
"encode_type": kw.get("encode_type"), "bits_per_sample": kw.get("bits_per_sample"), "sample_rate": kw.get("sample_rate"),
"playtime": kw.get("playtime", 0)}),
}
def _media_item(item_type: int, **kw: Any) -> Dict[str, Any]:
key, fields = _ITEM_SHAPES[item_type]
media = {"encrypt_query_param": kw["encrypt_query_param"], "aes_key": kw["aes_key_for_api"], "encrypt_type": 1}
return {"type": item_type, key: {"media": media, **fields(kw)}}
_file_item, _image_item = partial(_media_item, ITEM_FILE), partial(_media_item, ITEM_IMAGE)
_video_item, _voice_item = partial(_media_item, ITEM_VIDEO), partial(_media_item, ITEM_VOICE)
# Inbound media dispatch: item type -> (item key, download timeout, cache fn, mime or None (= guess from
# file_name), log label). Cache fns are lambdas so monkeypatching the module names takes effect at call time.
_INBOUND_MEDIA: Dict[int, Tuple[str, float, Callable[[bytes, str], Awaitable[str]], Optional[str], str]] = {
ITEM_IMAGE: ("image_item", 30.0, lambda data, _name: cache_image_from_bytes_async(data, ".jpg"), "image/jpeg", "image"),
ITEM_VIDEO: ("video_item", 120.0, lambda data, _name: cache_document_from_bytes_async(data, "video.mp4"), "video/mp4", "video"),
ITEM_FILE: ("file_item", 60.0, lambda data, name: cache_document_from_bytes_async(data, name), None, "file"),
ITEM_VOICE: ("voice_item", 60.0, lambda data, _name: cache_audio_from_bytes_async(data, ".silk"), "audio/silk", "voice"),
}
# Outbound local-file dispatch by extension: (extensions, sender method, path kwarg); default = send_document.
_OUTBOUND_BY_EXT: Tuple[Tuple[frozenset, str, str], ...] = (
(frozenset({".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}), "send_voice", "audio_path"), # no .m2a, unlike base
(_VIDEO_EXTS, "send_video", "video_path"),
(_IMAGE_EXTS, "send_image_file", "image_path"),
)
_DIRECT_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
class WeixinAdapter(OwnAccessPolicyMixin, BasePlatformAdapter):
ALLOW_ALL_ENV_PREFIX = "WEIXIN"
supports_code_blocks = True # Weixin renders fenced code blocks
splits_long_messages = True # send() chunks via _split_text()
MAX_MESSAGE_LENGTH = 2000
SUPPORTS_MESSAGE_EDITING = False # WeChat cannot edit; streaming must send-final-only so the cursor (▉) is never left visible
_SPLIT_THRESHOLD = 1800 # iLink chunks at ~2048 chars
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.WEIXIN)
extra = config.extra or {}
self._hermes_home = hermes_home = str(get_hermes_home())
self._token_store = ContextTokenStore(hermes_home)
self._typing_cache = TypingTicketCache()
self._poll_session = self._send_session = None # type: Optional[aiohttp.ClientSession]
self._poll_task: Optional[asyncio.Task] = None
self._dedup = MessageDeduplicator(ttl_seconds=MESSAGE_DEDUP_TTL_SECONDS)
self._account_id = _extra_or_secret(extra, "account_id")
self._token = str(config.token or extra.get("token") or _wx_secret("WEIXIN_TOKEN", "")).strip()
self._base_url = _extra_or_secret(extra, "base_url", ILINK_BASE_URL).rstrip("/")
self._cdn_base_url = _extra_or_secret(extra, "cdn_base_url", WEIXIN_CDN_BASE_URL).rstrip("/")
# Tunables: ``extra.<key>`` else env ``WEIXIN_<KEY>`` (e.g. WEIXIN_SEND_CHUNK_RETRIES).
self._send_chunk_delay_seconds = float(_extra_or_secret(extra, "send_chunk_delay_seconds", "1.5"))
self._send_chunk_retries = int(_extra_or_secret(extra, "send_chunk_retries", "4"))
self._send_chunk_retry_delay_seconds = float(_extra_or_secret(extra, "send_chunk_retry_delay_seconds", "1.0"))
self._send_text_gate = asyncio.Lock()
self._rate_limit_circuit_threshold = max(1, int(_extra_or_secret(extra, "rate_limit_circuit_threshold", "1")))
self._rate_limit_circuit_window_seconds = float(_extra_or_secret(extra, "rate_limit_circuit_window_seconds", "30.0"))
self._rate_limit_circuit_open_seconds = float(_extra_or_secret(extra, "rate_limit_circuit_open_seconds", "30.0"))
self._rate_limit_circuit_until, self._rate_limit_events = 0.0, [] # type: float, List[float]
self._dm_policy = _extra_or_secret(extra, "dm_policy", "pairing").lower()
self._group_policy = _extra_or_secret(extra, "group_policy", "disabled").lower()
# ``extra`` wins even when falsy (an explicit empty list disables the env allowlist).
allow_from, group_allow_from = extra.get("allow_from"), extra.get("group_allow_from")
self._allow_from = self._coerce_list(_wx_secret("WEIXIN_ALLOWED_USERS", "") if allow_from is None else allow_from)
self._group_allow_from = self._coerce_list(_wx_secret("WEIXIN_GROUP_ALLOWED_USERS", "") if group_allow_from is None else group_allow_from)
self._split_multiline_messages = _coerce_bool(_extra_or_secret(extra, "split_multiline_messages", ""), default=False)
# Text debounce batching (Telegram pattern): iLink delivers messages individually, so rapid bursts would each
# trigger a separate agent run. Telegram cadence and ceilings (#44883); ``0`` dispatches immediately.
self._configure_text_batch_delays()
persisted = load_weixin_account(hermes_home, self._account_id) if self._account_id and not self._token else None
if persisted:
self._token = str(persisted.get("token") or "").strip()
self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/")
@staticmethod
def _coerce_list(value: Any) -> List[str]:
if value is None:
return []
items = value.split(",") if isinstance(value, str) else value if isinstance(value, (list, tuple, set)) else [value]
return [str(item).strip() for item in items if str(item).strip()]
async def connect(self, *, is_reconnect: bool = False) -> bool:
preflight = (
(check_weixin_requirements(), "weixin_missing_dependency", "aiohttp and cryptography are required"),
(self._token, "weixin_missing_token", "WEIXIN_TOKEN is required"),
(self._account_id, "weixin_missing_account", "WEIXIN_ACCOUNT_ID is required"))
for ok, code, reason in preflight:
if not ok:
self._set_fatal_error(code, f"Weixin startup failed: {reason}", retryable=False)
logger.warning("[%s] Weixin startup failed: %s", self.name, reason)
return False
try:
if not self._acquire_platform_lock('weixin-bot-token', self._token, 'Weixin bot token'):
return False
except Exception as exc:
logger.debug("[%s] Token lock unavailable (non-fatal): %s", self.name, exc)
self._poll_session = _new_session()
# total=None disables aiohttp's ClientTimeout so send() works via run_coroutine_threadsafe() from cron;
# _api_post/_api_get enforce timeouts with asyncio.wait_for() instead.
self._send_session = _new_session(timeout=aiohttp.ClientTimeout(total=None, connect=None, sock_connect=None, sock_read=None))
self._token_store.restore(self._account_id)
self._poll_task = asyncio.create_task(self._poll_loop(), name="weixin-poll")
self._mark_connected()
_LIVE_ADAPTERS[self._token] = self
logger.info("[%s] Connected account=%s base=%s", self.name, _safe_id(self._account_id), self._base_url)
if self._group_policy != "disabled":
logger.warning(
"[%s] WEIXIN_GROUP_POLICY=%s is set, but QR-login connects an iLink bot identity (e.g. ...@im.bot) "
"which typically cannot be invited into ordinary WeChat groups. iLink usually does not deliver "
"ordinary-group events for these accounts, so group messages may never reach Hermes regardless of "
"this policy. If group delivery doesn't work, the limitation is on the iLink side, not in Hermes.",
self.name, self._group_policy)
self._wire_plugin_handlers(None) # plugin-registered native handlers
return True
async def disconnect(self) -> None:
_LIVE_ADAPTERS.pop(self._token, None)
self._running = False
for task in self._pending_text_batch_tasks.values():
if not task.done():
task.cancel()
self._pending_text_batches.clear()
self._pending_text_batch_tasks.clear()
await cancel_task(self._poll_task)
self._poll_task = None
for attr in ("_poll_session", "_send_session"):
session = getattr(self, attr)
if session and not session.closed:
await session.close()
setattr(self, attr, None)
self._release_platform_lock()
self._mark_disconnected()
logger.info("[%s] Disconnected", self.name)
async def _poll_loop(self) -> None:
assert self._poll_session is not None
sync_buf = _load_sync_buf(self._hermes_home, self._account_id)
timeout_ms = LONG_POLL_TIMEOUT_MS
consecutive_failures = 0
async def backoff() -> int:
"""Sleep for the failure streak; returns the new streak count (0 after a full streak)."""
streak_done = consecutive_failures >= MAX_CONSECUTIVE_FAILURES
await asyncio.sleep(BACKOFF_DELAY_SECONDS if streak_done else RETRY_DELAY_SECONDS)
return 0 if streak_done else consecutive_failures
while self._running:
try:
response = await _get_updates(self._poll_session, base_url=self._base_url, token=self._token, sync_buf=sync_buf, timeout_ms=timeout_ms)
suggested_timeout = response.get("longpolling_timeout_ms")
if isinstance(suggested_timeout, int) and suggested_timeout > 0:
timeout_ms = suggested_timeout
ret, errcode = response.get("ret", 0), response.get("errcode", 0)
if ret not in {0, None} or errcode not in {0, None}:
if _is_session_expired(response, ret, errcode):
logger.error("[%s] Session expired; pausing for 10 minutes", self.name)
await asyncio.sleep(600)
consecutive_failures = 0
continue
consecutive_failures += 1
logger.warning("[%s] getUpdates failed ret=%s errcode=%s errmsg=%s (%d/%d)", self.name, ret, errcode,
response.get("errmsg", ""), consecutive_failures, MAX_CONSECUTIVE_FAILURES)
consecutive_failures = await backoff()
continue
consecutive_failures = 0
# Dispatch before persisting: the off-loop write is an await, and a disconnect that
# cancels it must not leave the advanced cursor on disk with this batch undelivered.
for message in response.get("msgs") or []:
asyncio.create_task(self._process_message_safe(message))
# atomic_json_write fsyncs + renames: persist off the loop, and only when the cursor
# moved (an empty long-poll echoes the same buffer back every cycle).
if response.get("get_updates_buf") and str(response["get_updates_buf"]) != sync_buf:
sync_buf = str(response["get_updates_buf"])
await asyncio.to_thread(_save_sync_buf, self._hermes_home, self._account_id, sync_buf)
except asyncio.CancelledError:
break
except Exception as exc:
consecutive_failures += 1
logger.error("[%s] poll error (%d/%d): %s", self.name, consecutive_failures, MAX_CONSECUTIVE_FAILURES, exc)
consecutive_failures = await backoff()
if consecutive_failures == 0:
# Full failure streak: recycle the session. Failed connects through a local proxy (e.g.
# Clash) strand sockets the keepalive reaper never sees; on macOS the 256-fd soft limit
# then yields EMFILE and a crash. Closing the session tears down every socket.
# Clash on 127.0.0.1:7890) can strand sockets that never return to the connector's
# keepalive pool, so the tight keepalive_timeout never reaps them. On macOS the default
# 256-fd soft limit turns that drip into `[Errno 24] Too many open files` and a gateway
# crash (#79889). Closing the session tears down its connector and every socket it
# holds; a fresh session starts the next attempt from zero fds.
await self._recycle_poll_session()
async def _recycle_poll_session(self) -> None:
"""Swap in a fresh ``_poll_session`` *then* close the old one, so concurrent tasks never see a closed session."""
if not self._running or aiohttp is None:
return
old, self._poll_session = self._poll_session, _new_session()
if old is not None and not old.closed:
try:
await old.close()
except Exception as exc:
logger.debug("[%s] old poll session close failed: %s", self.name, exc)
async def _process_message_safe(self, message: Dict[str, Any]) -> None:
try:
await self._process_message(message)
except Exception as exc:
logger.error("[%s] unhandled inbound error from=%s: %s", self.name, _safe_id(message.get("from_user_id")), exc, exc_info=True)
async def _process_message(self, message: Dict[str, Any]) -> None:
assert self._poll_session is not None
sender_id = str(message.get("from_user_id") or "").strip()
message_id = str(message.get("message_id") or "").strip()
if not sender_id or sender_id == self._account_id or (message_id and self._dedup.is_duplicate(message_id)):
return
# Secondary content-fingerprint dedup: upstream re-sends identical text under new message_ids.
item_list = message.get("item_list") or []
text = _extract_text(item_list)
if text and self._dedup.is_duplicate(f"content:{sender_id}:{hashlib.md5(text.encode()).hexdigest()}"):
logger.debug("[%s] Content-dedup: skipping duplicate message from %s", self.name, sender_id)
return
chat_type, effective_chat_id = _guess_chat_type(message, self._account_id)
if chat_type == "group":
if not self._is_group_allowed(effective_chat_id):
return
elif not self._is_dm_intake_allowed(sender_id):
return
context_token = str(message.get("context_token") or "").strip()
if context_token:
await self._token_store.set(self._account_id, sender_id, context_token)
if self._poll_session and self._token and not self._typing_cache.get(sender_id):
asyncio.create_task(self._fetch_typing_ticket(self._poll_session, sender_id, context_token or None, "getConfig failed"))
media_paths, media_types = [], [] # type: List[str], List[str]
for item in item_list:
ref_item = (item.get("ref_msg") or {}).get("message_item")
for candidate in (item, ref_item) if isinstance(ref_item, dict) else (item,):
await self._collect_media(candidate, media_paths, media_types)
if not text and not media_paths:
return
source = self.build_source(chat_id=effective_chat_id, chat_type=chat_type, user_id=sender_id, user_name=sender_id)
event = MessageEvent(
text=text, message_type=_message_type_from_media(media_types, text), source=source, raw_message=message,
message_id=message_id or None, media_urls=media_paths, media_types=media_types, timestamp=datetime.now())
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
if event.message_type == MessageType.TEXT:
self._enqueue_text_event(event)
else:
await self.handle_message(event)
async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None:
spec = _INBOUND_MEDIA.get(item.get("type"))
path, mime = await self._download_media(item, spec) if spec else (None, "")
if path:
media_paths.append(path)
media_types.append(mime)
async def _download_media(self, item: Dict[str, Any], spec: Tuple[Any, ...]) -> Tuple[Optional[str], str]:
"""Download + decrypt one inbound media item -> (cached path or None, mime). Voice is always downloaded
(never trust Tencent's ``voice_item.text``) so gateway/run.py's central STT re-transcribes."""
item_key, timeout_seconds, cache_fn, mime, label = spec
payload = item.get(item_key) or {}
media = payload.get("media") or {}
filename = str(payload.get("file_name") or "document.bin")
mime = mime or mimetypes.guess_type(filename)[0] or "application/octet-stream"
try:
aes_key_b64 = media.get("aes_key")
if item_key == "image_item" and payload.get("aeskey"): # image_item may carry a raw hex ``aeskey`` beside the media block
aes_key_b64 = base64.b64encode(bytes.fromhex(str(payload.get("aeskey")))).decode("ascii") or aes_key_b64
# #27300: previously short-circuited when ``voice_item.text`` was set on the assumption that
# Tencent Cloud's STT was good enough. For non-Chinese audio that text is garbage (e.g. a
# Russian message comes back as English phonemes) — we must always download the raw audio so
# ``gateway/run.py``'s central STT pipeline can re-transcribe with the user's configured
# mlx-whisper / whisper.cpp / faster-whisper backend.
data = await _download_and_decrypt_media(
self._poll_session, cdn_base_url=self._cdn_base_url, encrypted_query_param=media.get("encrypt_query_param"),
aes_key_b64=aes_key_b64, full_url=media.get("full_url"), timeout_seconds=timeout_seconds)
return await cache_fn(data, filename), mime
except Exception as exc:
logger.warning("[%s] %s download failed: %s", self.name, label, exc)
return None, mime
async def _fetch_typing_ticket(self, session: Any, user_id: str, context_token: Optional[str], failure_label: str) -> Optional[str]:
try:
response = await _get_config(session, base_url=self._base_url, token=self._token, user_id=user_id, context_token=context_token)
typing_ticket = str(response.get("typing_ticket") or "")
if typing_ticket:
self._typing_cache.set(user_id, typing_ticket)
return typing_ticket
except Exception as exc:
logger.debug("[%s] %s for %s: %s", self.name, failure_label, _safe_id(user_id), exc)
return None
def _split_text(self, content: str) -> List[str]:
return _split_text_for_weixin_delivery(content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages)
def _rate_limit_cooldown_remaining(self) -> float:
return max(0.0, self._rate_limit_circuit_until - time.monotonic())
def _record_rate_limit_event(self) -> bool:
now = time.monotonic()
self._rate_limit_events = [ts for ts in self._rate_limit_events if ts >= now - self._rate_limit_circuit_window_seconds] + [now]
if len(self._rate_limit_events) < self._rate_limit_circuit_threshold:
return False
if self._rate_limit_circuit_open_seconds > 0:
self._rate_limit_circuit_until = max(self._rate_limit_circuit_until, time.monotonic() + self._rate_limit_circuit_open_seconds)
return self._rate_limit_cooldown_remaining() > 0
async def _send_text_chunk(self, *, chat_id: str, chunk: str, context_token: Optional[str], client_id: str) -> None:
"""Send one text chunk with retry/backoff under the adapter-wide text gate. A stale-session response (``-14``,
or ``-2`` with ``prepare failed``/``unknown error``) is re-sent once *without* ``context_token`` — iLink accepts
tokenless sends as a degraded fallback, which keeps cron pushes working when no user message refreshed the
session. A ``-2`` that survives that fails fast via ``_session_not_ready_error``."""
async with self._send_text_gate:
last_error: Optional[Exception] = None
retried_without_token = False
attempt = 0 # counts real failures only — the tokenless re-send must not eat the retry budget
while True:
if self._rate_limit_cooldown_remaining() > 0:
raise RuntimeError(f"iLink sendmessage rate limited; cooldown active for {self._rate_limit_cooldown_remaining():.1f}s")
try:
resp = await _send_message(
self._send_session, base_url=self._base_url, token=self._token, to=chat_id, text=chunk,
context_token=context_token, client_id=client_id)
ret, errcode = (resp.get("ret"), resp.get("errcode")) if resp and isinstance(resp, dict) else (None, None)
if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
errmsg = resp.get("errmsg") or resp.get("msg")
if _is_session_expired(resp, ret, errcode) and not retried_without_token and context_token:
retried_without_token, context_token = True, None
self._token_store._cache.pop(self._token_store._key(self._account_id, chat_id), None)
logger.warning("[%s] session expired for %s; retrying without context_token", self.name, _safe_id(chat_id))
continue
if _is_stale_session_ret(ret, errcode, errmsg):
# break, not raise: a raise here is caught below and re-enters the retry ladder.
last_error = _session_not_ready_error(ret, errcode, errmsg)
break
if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE:
raise RuntimeError(f"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg or 'unknown error'}")
# Keep a descriptive error for when the loop exhausts while still limited.
last_error = RuntimeError(f"iLink sendmessage rate limited: ret={ret} errcode={errcode} errmsg={errmsg or 'rate limited'}")
if self._record_rate_limit_event():
last_error = RuntimeError(
f"iLink sendmessage rate limited (ret={ret} errcode={errcode} errmsg={errmsg or 'rate limited'}); "
f"cooldown active for {self._rate_limit_cooldown_remaining():.1f}s")
break
if attempt >= self._send_chunk_retries:
break
attempt += 1
wait = self._send_chunk_retry_delay_seconds * 3 # 3x backoff for rate limit
logger.warning("[%s] rate limited for %s; backing off %.1fs before retry", self.name, _safe_id(chat_id), wait)
await asyncio.sleep(wait)
continue
self._rate_limit_events.clear()
self._rate_limit_circuit_until = 0.0
return
except Exception as exc:
last_error = exc
if attempt >= self._send_chunk_retries:
break
attempt += 1
wait = self._send_chunk_retry_delay_seconds * attempt
logger.warning("[%s] send chunk failed to=%s attempt=%d/%d, retrying in %.2fs: %s",
self.name, _safe_id(chat_id), attempt, self._send_chunk_retries + 1, wait, exc)
if wait > 0:
await asyncio.sleep(wait)
assert last_error is not None
raise last_error
async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
if not self._send_session or not self._token:
return SendResult(success=False, error="Not connected")
context_token = self._token_store.get(self._account_id, chat_id)
last_message_id: Optional[str] = None
# Extract MEDIA: tags and bare local file paths before text delivery, under the routed
# profile's scope: Docker MEDIA translation infers the sandbox from the active profile (#109024).
with self._media_delivery_scope(self.build_source(chat_id=chat_id)):
media_files, cleaned_content = self.extract_media(content)
local_files, final_content = self.extract_local_files(self.extract_images(cleaned_content)[1])
deliveries = [(p, v, "media") for p, v in self.filter_media_delivery_paths(media_files)]
deliveries += [(p, False, "local file") for p in self.filter_local_delivery_paths(local_files)]
try:
for path, is_voice, label in deliveries:
ext = Path(path).suffix.lower()
sender, key = next(((m, k) for exts, m, k in _OUTBOUND_BY_EXT if is_voice or ext in exts), ("send_document", "file_path"))
try:
await getattr(self, sender)(chat_id=chat_id, metadata=metadata, **{key: path})
except Exception as exc:
logger.warning("[%s] %s delivery failed for %s: %s", self.name, label, path, exc)
chunks = [c for c in self._split_text(self.format_message(final_content)) if c and c.strip()]
for idx, chunk in enumerate(chunks):
client_id = f"hermes-weixin-{uuid.uuid4().hex}"
await self._send_text_chunk(chat_id=chat_id, chunk=chunk, context_token=context_token, client_id=client_id)
last_message_id = client_id
if idx < len(chunks) - 1 and self._send_chunk_delay_seconds > 0:
await asyncio.sleep(self._send_chunk_delay_seconds)
return SendResult(success=True, message_id=last_message_id)
except Exception as exc:
logger.error("[%s] send failed to=%s: %s", self.name, _safe_id(chat_id), exc)
return SendResult(success=False, error=str(exc))
async def _ensure_typing_ticket(self, chat_id: str) -> Optional[str]:
"""Return a valid typing ticket, refreshing via getConfig once the 600s TTL evicts it —
otherwise ``stop_typing`` no-ops and the WeChat client shows the indicator forever."""
ticket = self._typing_cache.get(chat_id)
if ticket or not self._send_session or not self._token:
return ticket or None
return await self._fetch_typing_ticket(
self._send_session, chat_id, self._token_store.get(self._account_id, chat_id), "typing ticket refresh failed")
async def _set_typing(self, chat_id: str, status: int, label: str) -> None:
typing_ticket = await self._ensure_typing_ticket(chat_id)
if not typing_ticket:
return
try:
await _api_post(
self._send_session, base_url=self._base_url, endpoint=EP_SEND_TYPING, token=self._token, timeout_ms=CONFIG_TIMEOUT_MS,
payload={"ilink_user_id": chat_id, "typing_ticket": typing_ticket, "status": status})
except Exception as exc:
logger.debug("[%s] typing %s failed for %s: %s", self.name, label, _safe_id(chat_id), exc)
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
await self._set_typing(chat_id, TYPING_START, "start")
async def stop_typing(self, chat_id: str) -> None:
await self._set_typing(chat_id, TYPING_STOP, "stop")
async def send_image(self, chat_id: str, image_url: str, caption: str, reply_to: Optional[str] = None, metadata=None) -> SendResult:
cleanup = image_url.startswith(("http://", "https://"))
file_path = await self._download_remote_media(image_url) if cleanup else image_url.replace("file://", "")
if not cleanup and not os.path.isabs(file_path):
file_path = os.path.abspath(file_path)
try:
return await self.send_document(chat_id, file_path, caption=caption, metadata=metadata)
finally:
if cleanup and file_path and os.path.exists(file_path):
with contextlib.suppress(OSError):
os.unlink(file_path)
async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to=None, metadata=None, **kwargs) -> SendResult:
return await self.send_document(chat_id=chat_id, file_path=image_path, caption=caption, metadata=metadata)
async def _send_file_result(self, chat_id: str, path: str, caption: str, label: str, **kwargs: Any) -> SendResult:
if not self._send_session or not self._token:
return SendResult(success=False, error="Not connected")
try:
return SendResult(success=True, message_id=await self._send_file(chat_id, path, caption, **kwargs))
except Exception as exc:
logger.error("[%s] %s failed to=%s: %s", self.name, label, _safe_id(chat_id), exc)
return SendResult(success=False, error=str(exc))
async def send_document(
self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, **kwargs,
) -> SendResult:
return await self._send_file_result(chat_id, file_path, caption or "", "send_document")
async def send_video(self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to=None, metadata=None) -> SendResult:
return await self._send_file_result(chat_id, video_path, caption or "", "send_video")
async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to=None, metadata=None, **kwargs) -> SendResult:
# Native outbound voice bubbles are not proven-working upstream; a file attachment at least plays (even .silk).
return await self._send_file_result(chat_id, audio_path, caption or self.warning_text(t("platform.weixin.voice_as_attachment")), "send_voice", force_file_attachment=True)
async def _download_remote_media(self, url: str) -> str:
from tools.url_safety import is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {url}")
assert self._send_session is not None
data = await _download_bytes(self._send_session, url=url, timeout_seconds=30)
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(url.split("?", 1)[0]).suffix or ".bin") as handle:
handle.write(data)
return handle.name
async def _send_file(self, chat_id: str, path: str, caption: str, force_file_attachment: bool = False) -> str:
assert self._send_session is not None and self._token is not None
plaintext = Path(path).read_bytes()
media_type, item_builder = self._outbound_media_builder(path, force_file_attachment=force_file_attachment)
filekey, aes_key = secrets.token_hex(16), secrets.token_bytes(16)
rawsize, rawfilemd5 = len(plaintext), hashlib.md5(plaintext).hexdigest()
upload_response = await _get_upload_url(
self._send_session, base_url=self._base_url, token=self._token, to_user_id=chat_id, media_type=media_type, filekey=filekey,
rawsize=rawsize, rawfilemd5=rawfilemd5, filesize=((rawsize + 16) // 16) * 16, aeskey_hex=aes_key.hex())
upload_param = str(upload_response.get("upload_param") or "")
ciphertext = _aes128_ecb_encrypt(plaintext, aes_key)
# Prefer upload_full_url (direct CDN), else construct from upload_param. Both use POST — PUT 404s on the CDN.
upload_url = str(upload_response.get("upload_full_url") or "") or (upload_param and (
f"{self._cdn_base_url.rstrip('/')}/upload?encrypted_query_param={quote(upload_param, safe='')}&filekey={quote(filekey, safe='')}"))
if not upload_url:
raise RuntimeError(f"getUploadUrl returned neither upload_param nor upload_full_url: {upload_response}")
encrypted_query_param = await _upload_ciphertext(self._send_session, ciphertext=ciphertext, upload_url=upload_url)
context_token = self._token_store.get(self._account_id, chat_id)
# iLink expects aes_key as base64(hex_string), not base64(raw_bytes) — otherwise images render as grey boxes.
item_kwargs = {
"encrypt_query_param": encrypted_query_param, "aes_key_for_api": base64.b64encode(aes_key.hex().encode("ascii")).decode("ascii"),
"ciphertext_size": len(ciphertext), "plaintext_size": rawsize, "filename": Path(path).name, "rawfilemd5": rawfilemd5}
if media_type == MEDIA_VOICE and path.endswith(".silk"):
item_kwargs.update(encode_type=6, sample_rate=24000, bits_per_sample=16)
item_lists: List[List[Dict[str, Any]]] = [[item_builder(**item_kwargs)]]
if caption:
item_lists.insert(0, [{"type": ITEM_TEXT, "text_item": {"text": self.format_message(caption)}}])
last_message_id = ""
for item_list in item_lists:
last_message_id = f"hermes-weixin-{uuid.uuid4().hex}"
while True:
resp = await _send_items(
self._send_session, base_url=self._base_url, token=self._token, to=chat_id, item_list=item_list,
context_token=context_token, client_id=last_message_id)
ret, errcode = (resp.get("ret"), resp.get("errcode")) if resp and isinstance(resp, dict) else (None, None)
if (ret is None or ret == 0) and (errcode is None or errcode == 0):
break
# Same stale-session fallback as _send_text_chunk: re-send once without context_token. Clearing the
# token also covers the remaining item lists (caption, then media) and bounds this loop.
if _is_session_expired(resp, ret, errcode) and context_token:
context_token = None
self._token_store._cache.pop(self._token_store._key(self._account_id, chat_id), None)
logger.warning("[%s] session expired for %s; re-sending media without context_token", self.name, _safe_id(chat_id))
continue
errmsg = resp.get("errmsg") or resp.get("msg")
if _is_stale_session_ret(ret, errcode, errmsg):
raise _session_not_ready_error(ret, errcode, errmsg)
raise RuntimeError(f"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg or 'unknown error'}")
return last_message_id
def _outbound_media_builder(self, path: str, force_file_attachment: bool = False):
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
if mime.startswith("image/"):
return MEDIA_IMAGE, _image_item
if mime.startswith("video/"):
return MEDIA_VIDEO, _video_item
if path.endswith(".silk") and not force_file_attachment:
return MEDIA_VOICE, _voice_item
return MEDIA_FILE, _file_item # audio/* and everything else ship as file attachments
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": chat_id, "type": "group" if chat_id.endswith("@chatroom") else "dm", "chat_id": chat_id}
def format_message(self, content: Optional[str]) -> str:
return "" if content is None else _wrap_copy_friendly_lines_for_weixin(_normalize_markdown_blocks(content))
async def _deliver_direct(
adapter: WeixinAdapter, chat_id: str, message: str, media_files: Optional[List[Tuple[str, bool]]], context_token: Optional[str],
) -> Dict[str, Any]:
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}"}
for media_path, _is_voice in media_files or []:
sender = adapter.send_image_file if Path(media_path).suffix.lower() in _DIRECT_IMAGE_EXTS else adapter.send_document
last_result = await sender(chat_id, media_path)
if not last_result.success:
return {"error": f"Weixin media send failed: {last_result.error}"}
message_id = last_result.message_id if last_result else None
return {"success": True, "platform": "weixin", "chat_id": chat_id, "message_id": message_id, "context_token_used": bool(context_token)}
async def send_weixin_direct(
*, extra: Dict[str, Any], token: Optional[str], chat_id: str, message: str, media_files: Optional[List[Tuple[str, bool]]] = None,
) -> Dict[str, Any]:
"""One-shot send for ``send_message``/cron: reuse the live adapter's session on this loop, else a throwaway adapter."""
account_id = _extra_or_secret(extra, "account_id")
base_url = _extra_or_secret(extra, "base_url", ILINK_BASE_URL).rstrip("/")
cdn_base_url = _extra_or_secret(extra, "cdn_base_url", WEIXIN_CDN_BASE_URL).rstrip("/")
resolved_token = str(token or extra.get("token") or _wx_secret("WEIXIN_TOKEN", "")).strip()
if not resolved_token:
return {"error": "Weixin token missing. Configure WEIXIN_TOKEN or platforms.weixin.token."}
if not account_id:
return {"error": "Weixin account ID missing. Configure WEIXIN_ACCOUNT_ID or platforms.weixin.extra.account_id."}
token_store = ContextTokenStore(str(get_hermes_home()))
token_store.restore(account_id)
context_token = token_store.get(account_id, chat_id)
live_adapter = _LIVE_ADAPTERS.get(resolved_token)
send_session = getattr(live_adapter, '_send_session', None)
if send_session is not None and not send_session.closed and send_session._loop is asyncio.get_running_loop():
return await _deliver_direct(live_adapter, chat_id, message, media_files, context_token)
async with _new_session() as session:
merged = {**dict(extra or {}), "account_id": account_id, "base_url": base_url, "cdn_base_url": cdn_base_url}
adapter = WeixinAdapter(PlatformConfig(enabled=True, token=resolved_token, extra=merged))
adapter._send_session = adapter._session = session
adapter._token_store = token_store
return await _deliver_direct(adapter, chat_id, message, media_files, context_token)