* 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>
1244 lines
70 KiB
Python
1244 lines
70 KiB
Python
"""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)
|