Files
hermes-agent/gateway/platforms/weixin.py
T

1697 lines
76 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 platform adapter.
Connects Hermes Agent to WeChat personal accounts via Tencent's iLink Bot API.
Design notes:
- Long-poll ``getupdates`` drives inbound delivery.
- Every outbound reply must echo the latest ``context_token`` for the peer.
- Media files move through an AES-128-ECB encrypted CDN protocol.
- QR login is exposed as a helper for the gateway setup wizard.
"""
from __future__ import annotations
import asyncio
import base64
import contextlib
import hashlib
import json
import logging
import mimetypes
import os
import re
import secrets
import struct
import tempfile
import textwrap
import time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from urllib.parse import quote, urlparse
logger = logging.getLogger(__name__)
WEIXIN_COPY_LINE_WIDTH = 120
try:
import aiohttp
AIOHTTP_AVAILABLE = True
except ImportError: # pragma: no cover - dependency gate
aiohttp = None # type: ignore[assignment]
AIOHTTP_AVAILABLE = False
try:
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
CRYPTO_AVAILABLE = True
except ImportError: # pragma: no cover - dependency gate
default_backend = Cipher = algorithms = modes = None # type: ignore[assignment]
CRYPTO_AVAILABLE = False
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator, greedy_pack_blocks
from gateway.platforms.base import (
gateway_trust_env,
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
cache_audio_from_bytes,
cache_document_from_bytes,
cache_image_from_bytes,
)
from hermes_constants import get_hermes_home
from utils import atomic_json_write
from agent.secret_scope import UnscopedSecretError, get_secret
def _wx_secret(name: str, default: Optional[str] = None) -> Optional[str]:
"""Scope-aware WEIXIN_* read. Secondary profiles run scoped: a miss returns ``default``
(never borrow ``os.environ``). The DEFAULT profile runs *unscoped* under multiplexing,
where ``get_secret`` raises; there ``os.environ`` is its own value, so fall back."""
try:
return get_secret(name, default)
except UnscopedSecretError:
return os.getenv(name, default)
def _extra_or_secret(extra: Dict[str, Any], key: str, env: str, default: str = "") -> str:
"""``config.extra[key]`` first, else the scoped secret ``env``; stripped."""
return str(extra.get(key) or _wx_secret(env, default)).strip()
def _extra_or_env(extra: Dict[str, Any], key: str, env: str, default: str) -> Any:
"""``config.extra[key]`` first, else plain ``os.getenv(env, default)`` (non-secret tunables)."""
return extra.get(key) or os.getenv(env, default)
ILINK_BASE_URL = "https://ilinkai.weixin.qq.com"
WEIXIN_CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
ILINK_APP_ID = "bot"
CHANNEL_VERSION = "2.2.0"
ILINK_APP_CLIENT_VERSION = (2 << 16) | (2 << 8) | 0
EP_GET_UPDATES = "ilink/bot/getupdates"
EP_SEND_MESSAGE = "ilink/bot/sendmessage"
EP_SEND_TYPING = "ilink/bot/sendtyping"
EP_GET_CONFIG = "ilink/bot/getconfig"
EP_GET_UPLOAD_URL = "ilink/bot/getuploadurl"
EP_GET_BOT_QR = "ilink/bot/get_bot_qrcode"
EP_GET_QR_STATUS = "ilink/bot/get_qrcode_status"
LONG_POLL_TIMEOUT_MS = 35_000
API_TIMEOUT_MS = 15_000
CONFIG_TIMEOUT_MS = 10_000
QR_TIMEOUT_MS = 35_000
MAX_CONSECUTIVE_FAILURES = 3
RETRY_DELAY_SECONDS = 2
BACKOFF_DELAY_SECONDS = 30
SESSION_EXPIRED_ERRCODE = -14
RATE_LIMIT_ERRCODE = -2 # iLink frequency limit — backoff and retry
MESSAGE_DEDUP_TTL_SECONDS = 300
def _is_stale_session_ret(ret: "Optional[int]", errcode: "Optional[int]", errmsg: "Optional[str]") -> bool:
"""True when iLink returns ret/errcode=-2 with 'unknown error' — a stale-session
signal (same as errcode=-14) rather than a genuine rate limit."""
if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE:
return False
return (errmsg or "").lower() == "unknown error"
def _is_session_expired(resp: Dict[str, Any], ret: Any, errcode: Any) -> bool:
return (
ret == SESSION_EXPIRED_ERRCODE
or errcode == SESSION_EXPIRED_ERRCODE
or _is_stale_session_ret(ret, errcode, resp.get("errmsg"))
)
MEDIA_IMAGE = 1
MEDIA_VIDEO = 2
MEDIA_FILE = 3
MEDIA_VOICE = 4
_LIVE_ADAPTERS: Dict[str, Any] = {}
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."""
try:
import ssl
import certifi
except ImportError:
return None
if not AIOHTTP_AVAILABLE:
return None
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
return aiohttp.TCPConnector(ssl=ssl_ctx, 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)
ITEM_TEXT = 1
ITEM_IMAGE = 2
ITEM_VOICE = 3
ITEM_FILE = 4
ITEM_VIDEO = 5
MSG_TYPE_BOT = 2
MSG_STATE_FINISH = 2
TYPING_START = 1
TYPING_STOP = 2
_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 check_weixin_requirements() -> bool:
"""Return True when runtime dependencies for Weixin are available."""
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 _json_dumps(payload: Dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
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 _aes_cipher(key: bytes):
return Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend())
def _aes128_ecb_encrypt(plaintext: bytes, key: bytes) -> bytes:
encryptor = _aes_cipher(key).encryptor()
return encryptor.update(_pkcs7_pad(plaintext)) + encryptor.finalize()
def _aes128_ecb_decrypt(ciphertext: bytes, key: bytes) -> bytes:
decryptor = _aes_cipher(key).decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
if not padded:
return padded
pad_len = padded[-1]
if 1 <= pad_len <= 16 and padded.endswith(bytes([pad_len]) * pad_len):
return padded[:-pad_len]
return padded
def _aes_padded_size(size: int) -> int:
return ((size + 1 + 15) // 16) * 16
def _random_wechat_uin() -> str:
value = struct.unpack(">I", secrets.token_bytes(4))[0]
return base64.b64encode(str(value).encode("utf-8")).decode("ascii")
def _base_info() -> Dict[str, Any]:
return {"channel_version": CHANNEL_VERSION}
def _headers(token: Optional[str], body: str) -> Dict[str, str]:
headers = {
"Content-Type": "application/json",
"AuthorizationType": "ilink_bot_token",
"Content-Length": str(len(body.encode("utf-8"))),
"X-WECHAT-UIN": _random_wechat_uin(),
"iLink-App-Id": ILINK_APP_ID,
"iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
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:
"""Parse a JSON file; ``None`` when missing or unparseable."""
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def save_weixin_account(hermes_home: str, *, account_id: str, token: str, base_url: str, user_id: str = "") -> None:
"""Persist account credentials for later reuse."""
saved_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
path = _account_dir(hermes_home) / f"{account_id}.json"
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]]:
"""Load persisted account credentials."""
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] = {}
def _path(self, account_id: str) -> Path:
return self._root / f"{account_id}.context-tokens.json"
def _key(self, account_id: str, user_id: str) -> str:
return f"{account_id}:{user_id}"
def restore(self, account_id: str) -> None:
path = self._path(account_id)
if not path.exists():
return
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("weixin: failed to restore context tokens for %s: %s", _safe_id(account_id), exc)
return
restored = 0
for user_id, token in data.items():
if isinstance(token, str) and token:
self._cache[self._key(account_id, user_id)] = token
restored += 1
if restored:
logger.info("weixin: restored %d context token(s) for %s", 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))
def set(self, account_id: str, user_id: str, token: str) -> None:
self._cache[self._key(account_id, user_id)] = token
self._persist(account_id)
def _persist(self, account_id: str) -> None:
prefix = f"{account_id}:"
payload = {key[len(prefix):]: value for key, value in self._cache.items() if key.startswith(prefix)}
try:
atomic_json_write(self._path(account_id), 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 _cdn_download_url(cdn_base_url: str, encrypted_query_param: str) -> str:
return f"{cdn_base_url.rstrip('/')}/download?encrypted_query_param={quote(encrypted_query_param, safe='')}"
def _cdn_upload_url(cdn_base_url: str, upload_param: str, filekey: str) -> str:
return (
f"{cdn_base_url.rstrip('/')}/upload"
f"?encrypted_query_param={quote(upload_param, safe='')}"
f"&filekey={quote(filekey, safe='')}"
)
def _parse_aes_key(aes_key_b64: str) -> bytes:
decoded = base64.b64decode(aes_key_b64)
if len(decoded) == 16:
return decoded
if len(decoded) == 32:
text = decoded.decode("ascii", errors="ignore")
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()
is_group = bool(room_id) or (to_user_id and account_id and to_user_id != account_id and message.get("msg_type") == 1)
if is_group:
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 "")
# All HTTP helpers below enforce timeouts via asyncio.wait_for() rather than
# aiohttp ClientTimeout, which raises "Timeout context manager should be used
# inside a task" when invoked via asyncio.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]:
url = f"{base_url.rstrip('/')}/{endpoint}"
kwargs = {"data": body} if body is not None else {}
async def _do() -> Dict[str, Any]:
async with getattr(session, method.lower())(url, 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": _base_info()})
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]:
"""POST one ``sendmessage`` with the given item list; returns the raw response."""
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]:
"""Send a text message. Returns the raw API response (may carry ``errcode: -14`` etc.)."""
if not text or not text.strip():
raise ValueError("_send_message: text must not be empty")
return await _send_items(
session, base_url=base_url, token=token, to=to,
item_list=[{"type": ITEM_TEXT, "text_item": {"text": text}}],
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]:
return await _api_post(
session, base_url=base_url, endpoint=EP_GET_UPLOAD_URL,
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,
},
token=token, timeout_ms=API_TIMEOUT_MS,
)
async def _upload_ciphertext(session: "aiohttp.ClientSession", *, ciphertext: bytes, upload_url: str) -> str:
"""POST encrypted media to the CDN (constructed URL or direct ``upload_full_url``)."""
async def _do_upload() -> 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()
if response.status == 200:
raise RuntimeError(f"CDN upload missing x-encrypted-param header: {raw[:200]}")
raise RuntimeError(f"CDN upload HTTP {response.status}: {raw[:200]}")
return await asyncio.wait_for(_do_upload(), timeout=120)
async def _download_bytes(session: "aiohttp.ClientSession", *, url: str, timeout_seconds: float = 60.0) -> bytes:
async def _do_download() -> bytes:
async with session.get(url) as response:
response.raise_for_status()
return await response.read()
return await asyncio.wait_for(_do_download(), 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:
"""Raise ValueError if *url* does not point at a known WeChat CDN host."""
try:
parsed = urlparse(url)
scheme = parsed.scheme.lower()
host = 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:
raw = await _download_bytes(
session, url=_cdn_download_url(cdn_base_url, encrypted_query_param), timeout_seconds=timeout_seconds,
)
elif full_url:
_assert_weixin_cdn_url(full_url)
raw = await _download_bytes(session, url=full_url, timeout_seconds=timeout_seconds)
else:
raise RuntimeError("media item had neither encrypt_query_param nor full_url")
if aes_key_b64:
raw = _aes128_ecb_decrypt(raw, _parse_aes_key(aes_key_b64))
return raw
def _normalize_markdown_blocks(content: str) -> str:
result: List[str] = []
in_code_block = False
blank_run = 0
for raw_line in content.splitlines():
line = raw_line.rstrip()
if _FENCE_RE.match(line.strip()):
in_code_block = not in_code_block
result.append(line)
blank_run = 0
elif in_code_block:
result.append(line)
elif not line.strip():
blank_run += 1
if blank_run <= 1:
result.append("")
else:
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."""
if not content:
return content
wrapped: List[str] = []
in_code_block = False
for raw_line in content.splitlines():
line = raw_line.rstrip()
stripped = line.strip()
if _FENCE_RE.match(stripped):
in_code_block = not in_code_block
wrapped.append(line)
elif (
in_code_block
or len(line) <= WEIXIN_COPY_LINE_WIDTH
or not stripped
or stripped.startswith("|")
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]:
"""Split on blank lines, keeping each fenced code block as one block."""
if not content:
return []
blocks: List[str] = []
current: List[str] = []
in_code_block = False
def flush() -> None:
if current:
blocks.append("\n".join(current).strip())
current.clear()
for raw_line in content.splitlines():
line = raw_line.rstrip()
if _FENCE_RE.match(line.strip()):
if not in_code_block:
flush()
current.append(line)
in_code_block = not in_code_block
if not in_code_block:
flush()
elif in_code_block:
current.append(line)
elif not line.strip():
flush()
else:
current.append(line)
flush()
return [block for block in blocks if block]
def _split_delivery_units_for_weixin(content: str) -> List[str]:
"""Split formatted content into chat-friendly delivery units.
Top-level line breaks become separate messages; fenced code blocks stay
intact and indented continuation lines attach to the previous top-level
line so nested list items are not torn apart.
"""
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) # indented continuation
continue
if current:
units.append("\n".join(current).strip())
current = [line] if line.strip() else []
if current:
units.append("\n".join(current).strip())
return [unit for unit in units if unit]
def _looks_like_chatty_line_for_weixin(line: str) -> bool:
"""Return True when a line looks like a standalone chat utterance."""
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 _looks_like_heading_line_for_weixin(line: str) -> bool:
"""Return True when a short line behaves like a heading."""
stripped = line.strip()
if not stripped:
return False
return bool(_HEADER_RE.match(stripped)) or (len(stripped) <= 24 and stripped.endswith((":", ":")))
def _should_split_short_chat_block_for_weixin(block: str) -> bool:
"""Split only chat-like multiline blocks into separate bubbles."""
lines = [line for line in block.splitlines() if line.strip()]
if not 2 <= len(lines) <= 6:
return False
if _looks_like_heading_line_for_weixin(lines[0]):
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);
# the greedy packing loop is the shared core's.
return greedy_pack_blocks(
_split_markdown_blocks(content), max_length,
overflow=lambda block: BasePlatformAdapter.truncate_message(block, max_length),
)
def _split_text_for_weixin_delivery(content: str, max_length: int, split_per_line: bool = False) -> List[str]:
"""Split content into sequential Weixin messages.
Compact (default): one message when it fits — unless it reads as a short chatty
exchange, which becomes separate bubbles. Per-line (legacy, via
``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]
chunks = [
c for unit in _split_delivery_units_for_weixin(content)
for c in ([unit] if len(unit) <= max_length else _pack_markdown_blocks_for_weixin(unit, max_length))
]
return [c for c in chunks if c] or [content]
if len(content) <= max_length:
if _should_split_short_chat_block_for_weixin(content):
return [u for u in _split_delivery_units_for_weixin(content) if u]
return [content]
return _pack_markdown_blocks_for_weixin(content, max_length) or [content]
def _coerce_bool(value: Any, default: bool = True) -> bool:
"""Coerce a config value to bool, tolerating strings like ``"true"``."""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return 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 ""
prefix = f"[引用媒体: {title}]\n" if title else "[引用媒体]\n"
return f"{prefix}{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 the central
# STT pipeline in gateway/run.py transcribes the download instead.
voice_item = item.get("voice_item") or {}
if not (voice_item.get("media") or {}):
# No audio to download — use Weixin's transcript but mark its
# voice origin so the agent can tell it apart from typed text.
voice_text = str(voice_item.get("text") or "")
if 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
if media_types:
return MessageType.DOCUMENT
if text.startswith("/"):
return MessageType.COMMAND
return MessageType.TEXT
def _sync_buf_path(hermes_home: str, account_id: str) -> Path:
return _account_dir(hermes_home) / f"{account_id}.sync.json"
def _load_sync_buf(hermes_home: str, account_id: str) -> str:
data = _read_json(_sync_buf_path(hermes_home, account_id))
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(_sync_buf_path(hermes_home, account_id), {"get_updates_buf": sync_buf})
async def _fetch_qr(session: "aiohttp.ClientSession", bot_type: str) -> Tuple[str, str]:
"""Fetch a login QR; returns (qrcode hex token, qrcode_img_content URL)."""
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 and an ASCII rendering. WeChat must scan the full liteapp
URL (``qrcode_img_content``) when available, 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]]:
"""Run the interactive iLink QR login flow; credential dict on success, else ``None``."""
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 = ILINK_BASE_URL
refresh_count = 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 asyncio.TimeoutError:
await asyncio.sleep(1)
continue
except Exception as exc:
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":
redirect_host = str(status_resp.get("redirect_host") or "")
if redirect_host:
current_base_url = f"https://{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":
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 account_id or not token:
logger.error("weixin: QR confirmed but credential payload was incomplete")
return None
save_weixin_account(hermes_home, account_id=account_id, token=token, base_url=base_url, user_id=user_id)
print(f"\n微信连接成功,account_id={account_id}")
return {"account_id": account_id, "token": token, "base_url": base_url, "user_id": user_id}
await asyncio.sleep(1)
print("\n微信登录超时。")
return None
def _encrypted_media(kw: Dict[str, Any]) -> Dict[str, Any]:
return {"encrypt_query_param": kw["encrypt_query_param"], "aes_key": kw["aes_key_for_api"], "encrypt_type": 1}
def _file_item(**kw: Any) -> Dict[str, Any]:
return {
"type": ITEM_FILE,
"file_item": {"media": _encrypted_media(kw), "file_name": kw["filename"], "len": str(kw["plaintext_size"])},
}
def _image_item(**kw: Any) -> Dict[str, Any]:
return {"type": ITEM_IMAGE, "image_item": {"media": _encrypted_media(kw), "mid_size": kw["ciphertext_size"]}}
def _video_item(**kw: Any) -> Dict[str, Any]:
return {
"type": ITEM_VIDEO,
"video_item": {
"media": _encrypted_media(kw),
"video_size": kw["ciphertext_size"],
"play_length": kw.get("play_length", 0),
"video_md5": kw.get("rawfilemd5", ""),
},
}
def _voice_item(**kw: Any) -> Dict[str, Any]:
return {
"type": ITEM_VOICE,
"voice_item": {
"media": _encrypted_media(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),
},
}
# 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], str], Optional[str], str]] = {
ITEM_IMAGE: ("image_item", 30.0, lambda data, _name: cache_image_from_bytes(data, ".jpg"), "image/jpeg", "image"),
ITEM_VIDEO: ("video_item", 120.0, lambda data, _name: cache_document_from_bytes(data, "video.mp4"), "video/mp4", "video"),
ITEM_FILE: ("file_item", 60.0, lambda data, name: cache_document_from_bytes(data, name), None, "file"),
ITEM_VOICE: ("voice_item", 60.0, lambda data, _name: cache_audio_from_bytes(data, ".silk"), "audio/silk", "voice"),
}
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
_DIRECT_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
class WeixinAdapter(BasePlatformAdapter):
"""Native Hermes adapter for Weixin personal accounts."""
supports_code_blocks = True # Weixin renders fenced code blocks
splits_long_messages = True # send() chunks via _split_text()
MAX_MESSAGE_LENGTH = 2000
# WeChat cannot edit sent messages — streaming must use the send-final-only
# path so the cursor (▉) is never left visible.
SUPPORTS_MESSAGE_EDITING = False
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.WEIXIN)
extra = config.extra or {}
hermes_home = str(get_hermes_home())
self._hermes_home = hermes_home
self._token_store = ContextTokenStore(hermes_home)
self._typing_cache = TypingTicketCache()
self._poll_session: Optional[aiohttp.ClientSession] = None
self._send_session: Optional[aiohttp.ClientSession] = None
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", "WEIXIN_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", "WEIXIN_BASE_URL", ILINK_BASE_URL).rstrip("/")
self._cdn_base_url = _extra_or_secret(extra, "cdn_base_url", "WEIXIN_CDN_BASE_URL", WEIXIN_CDN_BASE_URL).rstrip("/")
self._send_chunk_delay_seconds = float(
_extra_or_env(extra, "send_chunk_delay_seconds", "WEIXIN_SEND_CHUNK_DELAY_SECONDS", "1.5")
)
self._send_chunk_retries = int(_extra_or_env(extra, "send_chunk_retries", "WEIXIN_SEND_CHUNK_RETRIES", "4"))
self._send_chunk_retry_delay_seconds = float(
_extra_or_env(extra, "send_chunk_retry_delay_seconds", "WEIXIN_SEND_CHUNK_RETRY_DELAY_SECONDS", "1.0")
)
self._send_text_gate = asyncio.Lock()
self._rate_limit_circuit_threshold = max(
1, int(_extra_or_env(extra, "rate_limit_circuit_threshold", "WEIXIN_RATE_LIMIT_CIRCUIT_THRESHOLD", "1"))
)
self._rate_limit_circuit_window_seconds = float(
_extra_or_env(extra, "rate_limit_circuit_window_seconds", "WEIXIN_RATE_LIMIT_CIRCUIT_WINDOW_SECONDS", "30.0")
)
self._rate_limit_circuit_open_seconds = float(
_extra_or_env(extra, "rate_limit_circuit_open_seconds", "WEIXIN_RATE_LIMIT_CIRCUIT_OPEN_SECONDS", "30.0")
)
self._rate_limit_circuit_until = 0.0
self._rate_limit_events: List[float] = []
self._dm_policy = _extra_or_secret(extra, "dm_policy", "WEIXIN_DM_POLICY", "pairing").lower()
self._group_policy = _extra_or_secret(extra, "group_policy", "WEIXIN_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.get("split_multiline_messages") or os.getenv("WEIXIN_SPLIT_MULTILINE_MESSAGES"), default=False,
)
# Text debounce batching (Telegram pattern): iLink delivers messages individually, so rapid
# bursts would each trigger a separate agent run. 3s / 5s (after a ~2048-char split chunk)
# suit iLink's cadence; tunable via ``extra.text_batch_delay_seconds`` / ``text_batch_split_delay_seconds``.
self._text_batch_delay_seconds = self._coerce_float_extra("text_batch_delay_seconds", 3.0)
self._text_batch_split_delay_seconds = self._coerce_float_extra("text_batch_split_delay_seconds", 5.0)
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
if self._account_id and not self._token:
persisted = load_weixin_account(hermes_home, self._account_id)
if persisted:
self._token = str(persisted.get("token") or "").strip()
self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/")
def _coerce_float_extra(self, key: str, default: float) -> float:
"""Float from ``config.extra``; fed to ``asyncio.sleep()``, so NaN/Inf/negative/unparseable → default."""
import math
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
try:
parsed = float(value) if value is not None else float(default)
except (TypeError, ValueError):
return float(default)
return parsed if math.isfinite(parsed) and parsed >= 0 else float(default)
@staticmethod
def _coerce_list(value: Any) -> List[str]:
if value is None:
return []
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
return [str(value).strip()] if str(value).strip() else []
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:
message = f"Weixin startup failed: {reason}"
self._set_fatal_error(code, message, retryable=False)
logger.warning("[%s] %s", self.name, message)
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,
)
# Plugin-registered native handlers (ctx.register_platform_handler).
self._wire_plugin_handlers(None)
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()
if self._poll_task and not self._poll_task.done():
self._poll_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await 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)
@staticmethod
async def _poll_backoff(consecutive_failures: int) -> 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
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
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 = response.get("ret", 0)
errcode = 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 self._poll_backoff(consecutive_failures)
continue
consecutive_failures = 0
new_sync_buf = str(response.get("get_updates_buf") or "")
if new_sync_buf:
sync_buf = new_sync_buf
_save_sync_buf(self._hermes_home, self._account_id, sync_buf)
for message in response.get("msgs") or []:
asyncio.create_task(self._process_message_safe(message))
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 self._poll_backoff(consecutive_failures)
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 its connector and every socket.
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
``_process_message`` tasks never observe 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()
if not sender_id or sender_id == self._account_id:
return
message_id = str(message.get("message_id") or "").strip()
if 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:
content_key = f"content:{sender_id}:{hashlib.md5(text.encode()).hexdigest()}"
if self._dedup.is_duplicate(content_key):
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 self._group_policy == "disabled" or self._group_policy == "pairing":
return
if self._group_policy == "allowlist" and effective_chat_id not in self._group_allow_from:
return
elif not self._is_dm_intake_allowed(sender_id):
return
context_token = str(message.get("context_token") or "").strip()
if context_token:
self._token_store.set(self._account_id, sender_id, context_token)
asyncio.create_task(self._maybe_fetch_typing_ticket(sender_id, context_token or None))
media_paths: List[str] = []
media_types: List[str] = []
for item in item_list:
await self._collect_media(item, media_paths, media_types)
ref_item = (item.get("ref_msg") or {}).get("message_item")
if isinstance(ref_item, dict):
await self._collect_media(ref_item, 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)
def _open_dm_opted_in(self) -> bool:
# Scoped reads: the default profile's allow-all flag must not leak into a
# multiplexed secondary profile's admission gate.
if (_wx_secret("GATEWAY_ALLOW_ALL_USERS", "") or "").lower() in {"true", "1", "yes"}:
return True
return (_wx_secret("WEIXIN_ALLOW_ALL_USERS", "") or "").lower() in {"true", "1", "yes"}
def _is_dm_allowed(self, sender_id: str) -> bool:
if self._dm_policy == "allowlist":
return sender_id in self._allow_from
if self._dm_policy == "open":
return self._open_dm_opted_in()
return False
def _is_dm_intake_allowed(self, sender_id: str) -> bool:
"""Like ``_is_dm_allowed`` but ``pairing`` admits everyone at intake (pairing gate runs later)."""
return self._dm_policy == "pairing" or self._is_dm_allowed(sender_id)
@property
def enforces_own_access_policy(self) -> bool:
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
return True
# ------------------------------------------------------------------
# Text debounce batching
# ------------------------------------------------------------------
_SPLIT_THRESHOLD = 1800 # iLink chunks at ~2048 chars
def _text_batch_key(self, event: MessageEvent) -> str:
"""Session-scoped key for text message batching."""
from gateway.session import build_session_key
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
profile=event.source.profile,
)
async def _flush_text_batch(self, key: str) -> None:
"""Wait for quiet period then dispatch aggregated text."""
current_task = asyncio.current_task()
try:
pending = self._pending_text_batches.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
split = last_len >= self._SPLIT_THRESHOLD
await asyncio.sleep(self._text_batch_split_delay_seconds if split else self._text_batch_delay_seconds)
if self._pending_text_batch_tasks.get(key) is not current_task:
return
event = self._pending_text_batches.pop(key, None)
if not event:
return
await self.handle_message(event)
finally:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)
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"))
if spec is None:
return
path, mime = await self._download_media(item, spec)
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; returns (cached path or None, mime)."""
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")
if mime is None:
mime = 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
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 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 _download_voice(self, item: Dict[str, Any]) -> Optional[str]:
# Always download raw audio (never trust Tencent's ``voice_item.text``) so
# gateway/run.py's central STT can re-transcribe with the user's backend.
return (await self._download_media(item, _INBOUND_MEDIA[ITEM_VOICE]))[0]
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
async def _maybe_fetch_typing_ticket(self, user_id: str, context_token: Optional[str]) -> None:
if not self._poll_session or not self._token or self._typing_cache.get(user_id):
return
await self._fetch_typing_ticket(self._poll_session, user_id, context_token, "getConfig failed")
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 _rate_limit_error(self) -> RuntimeError:
return RuntimeError(
f"iLink sendmessage rate limited; cooldown active for {self._rate_limit_cooldown_remaining():.1f}s"
)
def _record_rate_limit_event(self) -> bool:
"""Record a genuine iLink rate limit and return True if breaker opened."""
now = time.monotonic()
window_start = now - self._rate_limit_circuit_window_seconds
self._rate_limit_events = [ts for ts in self._rate_limit_events if ts >= window_start]
self._rate_limit_events.append(now)
if len(self._rate_limit_events) >= self._rate_limit_circuit_threshold:
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
return False
def _reset_rate_limit_circuit(self) -> None:
self._rate_limit_events.clear()
self._rate_limit_circuit_until = 0.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. On session-expired
(errcode -14) retry once *without* ``context_token`` — iLink accepts tokenless sends as a
degraded fallback, which keeps cron pushes working when no user message refreshed the session."""
async with self._send_text_gate:
await self._send_text_chunk_locked(chat_id=chat_id, chunk=chunk, context_token=context_token, client_id=client_id)
async def _send_text_chunk_locked(self, *, chat_id: str, chunk: str, context_token: Optional[str], client_id: str) -> None:
last_error: Optional[Exception] = None
retried_without_token = False
for attempt in range(self._send_chunk_retries + 1):
if self._rate_limit_cooldown_remaining() > 0:
raise self._rate_limit_error()
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,
)
if resp and isinstance(resp, dict):
ret = resp.get("ret")
errcode = resp.get("errcode")
if (ret is not None and ret not in {0}) or (errcode is not None and errcode not in {0}):
if _is_session_expired(resp, ret, errcode) and not retried_without_token and context_token:
retried_without_token = True
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; retrying without context_token", self.name, _safe_id(chat_id),
)
continue
if ret == RATE_LIMIT_ERRCODE or errcode == RATE_LIMIT_ERRCODE:
errmsg = resp.get("errmsg") or resp.get("msg") or "rate limited"
# 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}"
)
if self._record_rate_limit_event():
last_error = self._rate_limit_error()
break
if attempt >= self._send_chunk_retries:
break
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
errmsg = resp.get("errmsg") or resp.get("msg") or "unknown error"
raise RuntimeError(f"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg}")
self._reset_rate_limit_circuit()
return
except Exception as exc:
last_error = exc
if attempt >= self._send_chunk_retries:
break
wait = self._send_chunk_retry_delay_seconds * (attempt + 1)
logger.warning(
"[%s] send chunk failed to=%s attempt=%d/%d, retrying in %.2fs: %s",
self.name, _safe_id(chat_id), attempt + 1, 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.
media_files, cleaned_content = self.extract_media(content)
media_files = self.filter_media_delivery_paths(media_files)
_, image_cleaned = self.extract_images(cleaned_content)
local_files, final_content = self.extract_local_files(image_cleaned)
local_files = self.filter_local_delivery_paths(local_files)
deliveries = [(p, v, "media") for p, v in media_files] + [(p, False, "local file") for p in local_files]
try:
for path, is_voice, label in deliveries:
ext = Path(path).suffix.lower()
if is_voice or ext in _AUDIO_EXTS:
sender, key = self.send_voice, "audio_path"
elif ext in _VIDEO_EXTS:
sender, key = self.send_video, "video_path"
elif ext in _IMAGE_EXTS:
sender, key = self.send_image_file, "image_path"
else:
sender, key = self.send_document, "file_path"
try:
await 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:
return ticket
if not self._send_session or not self._token:
return None
context_token = self._token_store.get(self._account_id, chat_id)
return await self._fetch_typing_ticket(self._send_session, chat_id, context_token, "typing ticket refresh failed")
async def _set_typing(self, chat_id: str, status: int, label: str) -> None:
if not self._send_session or not self._token:
return
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,
payload={"ilink_user_id": chat_id, "typing_ticket": typing_ticket, "status": status},
token=self._token, timeout_ms=CONFIG_TIMEOUT_MS,
)
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: Optional[Dict[str, Any]] = None,
) -> SendResult:
if image_url.startswith(("http://", "https://")):
file_path = await self._download_remote_media(image_url)
cleanup = True
else:
file_path = image_url.replace("file://", "")
if not os.path.isabs(file_path):
file_path = os.path.abspath(file_path)
cleanup = False
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: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None, **kwargs,
) -> SendResult:
del reply_to, kwargs
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:
message_id = await self._send_file(chat_id, path, caption, **kwargs)
return SendResult(success=True, message_id=message_id)
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:
del file_name, reply_to, metadata, kwargs
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: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = 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: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
# Native outbound voice bubbles are not proven-working upstream; send a
# file attachment so users at least receive playable audio (even .silk).
return await self._send_file_result(
chat_id, audio_path, caption or "[voice message 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)
suffix = Path(url.split("?", 1)[0]).suffix or ".bin"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) 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 = secrets.token_hex(16)
aes_key = secrets.token_bytes(16)
rawsize = len(plaintext)
rawfilemd5 = 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=_aes_padded_size(rawsize), aeskey_hex=aes_key.hex(),
)
upload_param = str(upload_response.get("upload_param") or "")
upload_full_url = str(upload_response.get("upload_full_url") or "")
ciphertext = _aes128_ecb_encrypt(plaintext, aes_key)
# Prefer upload_full_url (direct CDN), else construct from upload_param.
# Both use POST — PUT to upload_full_url 404s on the WeChat CDN.
upload_url = upload_full_url or (upload_param and _cdn_upload_url(self._cdn_base_url, upload_param, filekey))
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 because the key doesn't match.
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)
media_item = item_builder(**item_kwargs)
if caption:
await _send_message(
self._send_session, base_url=self._base_url, token=self._token, to=chat_id,
text=self.format_message(caption), context_token=context_token,
client_id=f"hermes-weixin-{uuid.uuid4().hex}",
)
last_message_id = f"hermes-weixin-{uuid.uuid4().hex}"
await _send_items(
self._send_session, base_url=self._base_url, token=self._token, to=chat_id,
item_list=[media_item], context_token=context_token, client_id=last_message_id,
)
return last_message_id
def _outbound_media_builder(self, path: str, force_file_attachment: bool = False):
"""Return (iLink media_type, item builder) for an outbound file."""
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]:
chat_type = "group" if chat_id.endswith("@chatroom") else "dm"
return {"name": chat_id, "type": chat_type, "chat_id": chat_id}
def format_message(self, content: Optional[str]) -> str:
if content is None:
return ""
return _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 []:
if Path(media_path).suffix.lower() in _DIRECT_IMAGE_EXTS:
last_result = await adapter.send_image_file(chat_id, media_path)
else:
last_result = await adapter.send_document(chat_id, media_path)
if not last_result.success:
return {"error": f"Weixin media send failed: {last_result.error}"}
return {
"success": True,
"platform": "weixin",
"chat_id": chat_id,
"message_id": last_result.message_id if last_result else None,
"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 helper for ``send_message`` and cron delivery.
Reuses the live adapter's session when one is connected on this loop;
otherwise builds a throwaway adapter over a fresh session.
"""
account_id = _extra_or_secret(extra, "account_id", "WEIXIN_ACCOUNT_ID")
base_url = _extra_or_secret(extra, "base_url", "WEIXIN_BASE_URL", ILINK_BASE_URL).rstrip("/")
cdn_base_url = _extra_or_secret(extra, "cdn_base_url", "WEIXIN_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 (live_adapter is not None and 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:
adapter = WeixinAdapter(
PlatformConfig(
enabled=True,
token=resolved_token,
extra={**dict(extra or {}), "account_id": account_id, "base_url": base_url, "cdn_base_url": cdn_base_url},
)
)
adapter._send_session = adapter._session = session
adapter._token, adapter._account_id = resolved_token, account_id
adapter._base_url, adapter._cdn_base_url = base_url, cdn_base_url
adapter._token_store = token_store
return await _deliver_direct(adapter, chat_id, message, media_files, context_token)