From 0ae201f3084ac68238c51f22d3038070009e2d97 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:09:26 -0700 Subject: [PATCH 01/10] refactor(gateway/yuanbao-proto): table-driven send encoders, shared _conn_request/_parse_repeated, drop DEBUG_MODE/_dbg --- gateway/platforms/yuanbao_proto.py | 408 ++++++++++------------------- 1 file changed, 134 insertions(+), 274 deletions(-) diff --git a/gateway/platforms/yuanbao_proto.py b/gateway/platforms/yuanbao_proto.py index fce9585d08..b823986fd4 100644 --- a/gateway/platforms/yuanbao_proto.py +++ b/gateway/platforms/yuanbao_proto.py @@ -1,52 +1,26 @@ """ -yuanbao_proto.py - Yuanbao WebSocket 协议编解码(纯 Python 实现) +yuanbao_proto.py - Yuanbao WebSocket 协议编解码(手写 protobuf wire-format,不依赖 google.protobuf) -协议层级: - WebSocket frame - └── ConnMsg (protobuf: trpc.yuanbao.conn_common.ConnMsg) - ├── head: Head (cmd_type, cmd, seq_no, msg_id, module, ...) - └── data: bytes (业务 payload,标准 protobuf) - └── InboundMessagePush / SendC2CMessageReq / SendGroupMessageReq / ... - (trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.*) - -注意:conn 层(ConnMsg)本身是标准 protobuf,不是自定义二进制格式;conn.proto 注释里的 -magic+head_len+body_len 格式仅用于 quic/tcp。WebSocket 每个 frame = 一条 ConnMsg protobuf。 - -实现方式:手写 varint / protobuf wire-format 编解码,不依赖第三方 protobuf 库。 +每个 WebSocket frame = 一条 ConnMsg protobuf(标准 protobuf,非 conn.proto 注释里的 magic+len 二进制格式, +那只用于 quic/tcp): + ConnMsg { Head head=1 (cmd_type, cmd, seq_no, msg_id, module, ...); bytes data=2 } + data = 业务 payload(InboundMessagePush / SendC2CMessageReq / SendGroupMessageReq / ..., + 包 trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.*) """ from __future__ import annotations -import logging import threading +import time from typing import Optional -logger = logging.getLogger(__name__) - -DEBUG_MODE = False - - -def _dbg(label: str, data: bytes) -> None: - if DEBUG_MODE: - hex_str = " ".join(f"{b:02x}" for b in data[:64]) - ellipsis = "..." if len(data) > 64 else "" - logger.debug("[yuanbao_proto] %s (%dB): %s", label, len(data), hex_str + ellipsis) - - -# ============================================================ -# 常量 -# ============================================================ - # conn 层消息类型(ConnMsg.Head.cmd_type) PB_MSG_TYPES = { n: f"trpc.yuanbao.conn_common.{n}" for n in ("ConnMsg", "AuthBindReq", "AuthBindRsp", "PingReq", "PingRsp", "KickoutMsg", "DirectedPush", "PushMsg") } - # cmd_type: 上行请求 / 请求回包 / 下行推送 / 推送 ACK CMD_TYPE = {"Request": 0, "Response": 1, "Push": 2, "PushAck": 3} - -# 内置命令字 / 模块名 CMD = {"AuthBind": "auth-bind", "Ping": "ping", "Kickout": "kickout", "UpdateMeta": "update-meta"} MODULE = {"ConnAccess": "conn_access"} @@ -65,17 +39,10 @@ BIZ_SERVICES = { ) } -# openclaw instance_id(固定值 17) -HERMES_INSTANCE_ID = 17 - -# Reply Heartbeat 状态常量 +HERMES_INSTANCE_ID = 17 # openclaw instance_id(固定值) WS_HEARTBEAT_RUNNING = 1 WS_HEARTBEAT_FINISH = 2 -# ============================================================ -# 序列号生成 -# ============================================================ - _seq_lock = threading.Lock() _seq_counter = 0 _SEQ_MAX = 2 ** 32 - 1 # uint32 上限 @@ -91,7 +58,7 @@ def next_seq_no() -> int: # ============================================================ -# Protobuf wire-format 基础工具(手写,不依赖 google.protobuf) +# Protobuf wire-format 基础工具 # ============================================================ WT_VARINT = 0 @@ -102,19 +69,17 @@ _FIXED_SIZE = {WT_64BIT: 8, WT_32BIT: 4} def _encode_varint(value: int) -> bytes: - """将整数编码为 protobuf varint(负数按 64-bit two's complement)""" + """protobuf varint(负数按 64-bit two's complement)""" if value < 0: - value = value & 0xFFFFFFFFFFFFFFFF + value &= 0xFFFFFFFFFFFFFFFF out = [] while True: bits = value & 0x7F value >>= 7 - if value: - out.append(bits | 0x80) - else: + if not value: out.append(bits) - break - return bytes(out) + return bytes(out) + out.append(bits | 0x80) def _decode_varint(data: bytes, pos: int) -> tuple[int, int]: @@ -134,21 +99,18 @@ def _decode_varint(data: bytes, pos: int) -> tuple[int, int]: def _encode_field(field_number: int, wire_type: int, value: bytes) -> bytes: - """编码一个 protobuf field(tag + value)""" return _encode_varint((field_number << 3) | wire_type) + value -def _encode_string(s: str) -> bytes: - """length-prefixed UTF-8 string value""" - encoded = s.encode("utf-8") - return _encode_varint(len(encoded)) + encoded - - def _encode_message(b: bytes) -> bytes: """length-prefixed bytes / 嵌套 message value""" return _encode_varint(len(b)) + b +def _encode_string(s: str) -> bytes: + return _encode_message(s.encode("utf-8")) + + # 完整 field 编码快捷方式:string / varint / 嵌套 message def _s(fn: int, s: str) -> bytes: return _encode_field(fn, WT_LEN, _encode_string(s)) @@ -163,33 +125,29 @@ def _m(fn: int, b: bytes) -> bytes: def _parse_fields(data: bytes) -> list[tuple[int, int, bytes | int]]: - """解析 message 的所有字段 → [(field_number, wire_type, raw_value)]; - raw_value 为 int(VARINT)或 bytes(LEN / 64BIT / 32BIT)。""" + """→ [(field_number, wire_type, raw_value)];raw_value 为 int(VARINT)或 bytes(LEN / 64BIT / 32BIT)""" fields = [] pos = 0 - n = len(data) - while pos < n: + while pos < len(data): tag, pos = _decode_varint(data, pos) - field_number = tag >> 3 wire_type = tag & 0x07 if wire_type == WT_VARINT: val, pos = _decode_varint(data, pos) - elif wire_type == WT_LEN: - length, pos = _decode_varint(data, pos) + else: + if wire_type == WT_LEN: + length, pos = _decode_varint(data, pos) + elif wire_type in _FIXED_SIZE: + length = _FIXED_SIZE[wire_type] + else: + raise ValueError(f"unknown wire type {wire_type} at pos {pos - 1}") val = data[pos: pos + length] pos += length - elif wire_type in _FIXED_SIZE: - size = _FIXED_SIZE[wire_type] - val = data[pos: pos + size] - pos += size - else: - raise ValueError(f"unknown wire type {wire_type} at pos {pos - 1}") - fields.append((field_number, wire_type, val)) + fields.append((tag >> 3, wire_type, val)) return fields def _fields_to_dict(fields: list) -> dict[int, list]: - """fields 列表 → {field_number: [(wire_type, value), ...]}(repeated 字段有多个)""" + """→ {field_number: [(wire_type, value), ...]}(repeated 字段有多个)""" d: dict[int, list] = {} for fn, wt, val in fields: d.setdefault(fn, []).append((wt, val)) @@ -201,18 +159,14 @@ def _parse_dict(data: bytes) -> dict[int, list]: def _first(fdict: dict, fn: int, wt: int): - """第一个 wire type 匹配的字段值,无则 None""" + """第一个字段值(仅当其 wire type 匹配),无则 None""" entries = fdict.get(fn) - if entries and entries[0][0] == wt: - return entries[0][1] - return None + return entries[0][1] if entries and entries[0][0] == wt else None def _get_string(fdict: dict, fn: int, default: str = "") -> str: val = _first(fdict, fn, WT_LEN) - if isinstance(val, (bytes, bytearray)): - return val.decode("utf-8", errors="replace") - return default + return val.decode("utf-8", errors="replace") if isinstance(val, (bytes, bytearray)) else default def _get_varint(fdict: dict, fn: int, default: int = 0) -> int: @@ -229,6 +183,10 @@ def _get_repeated_bytes(fdict: dict, fn: int) -> list[bytes]: return [bytes(val) for wt, val in fdict.get(fn, []) if wt == WT_LEN] +def _parse_repeated(fdict: dict, fn: int) -> list[dict]: + return [_parse_dict(b) for b in _get_repeated_bytes(fdict, fn)] + + # 字段表驱动编解码:spec = [(field_number, key, kind)],kind: # "s" string(编码时 str(v)) "r" string(原值) "i" varint(编码时 int(v)) # 编码跳过 falsy 值;解码只保留 truthy 值。spec 顺序即 wire 顺序和 dict 插入顺序。 @@ -255,10 +213,10 @@ def _decode_spec(fdict: dict, spec: list) -> dict: # ============================================================ # ConnMsg 层编解码 -# ============================================================ # message Head { uint32 cmd_type=1; string cmd=2; uint32 seq_no=3; string msg_id=4; # string module=5; bool need_ack=6; ... int32 status=10; } # message ConnMsg { Head head=1; bytes data=2; } +# ============================================================ def _encode_head( @@ -285,44 +243,36 @@ def _encode_head( def _decode_head(data: bytes) -> dict: fdict = _parse_dict(data) return { - "cmd_type": _get_varint(fdict, 1, 0), - "cmd": _get_string(fdict, 2, ""), - "seq_no": _get_varint(fdict, 3, 0), - "msg_id": _get_string(fdict, 4, ""), - "module": _get_string(fdict, 5, ""), - "need_ack": bool(_get_varint(fdict, 6, 0)), - "status": _get_varint(fdict, 10, 0), + "cmd_type": _get_varint(fdict, 1), "cmd": _get_string(fdict, 2), "seq_no": _get_varint(fdict, 3), + "msg_id": _get_string(fdict, 4), "module": _get_string(fdict, 5), + "need_ack": bool(_get_varint(fdict, 6)), "status": _get_varint(fdict, 10), } -def _conn_msg(label: str, cmd_type: int, cmd: str, seq_no: int, msg_id: str, module: str, data: bytes, need_ack: bool) -> bytes: - buf = _m(1, _encode_head(cmd_type, cmd, seq_no, msg_id, module, need_ack)) - if data: - buf += _m(2, data) - _dbg(label, buf) - return buf - - -def encode_conn_msg(msg_type: int, seq_no: int, data: bytes) -> bytes: - """编码 ConnMsg(简化接口:仅 cmd_type + seq_no + payload)""" - return _conn_msg("encode_conn_msg", msg_type, "", seq_no, "", "", data, False) - - def encode_conn_msg_full( cmd_type: int, cmd: str, seq_no: int, msg_id: str, module: str, data: bytes, need_ack: bool = False, ) -> bytes: """编码完整的 ConnMsg(含 cmd/msg_id/module 等 head 字段)""" - return _conn_msg("encode_conn_msg_full", cmd_type, cmd, seq_no, msg_id, module, data, need_ack) + buf = _m(1, _encode_head(cmd_type, cmd, seq_no, msg_id, module, need_ack)) + return buf + _m(2, data) if data else buf + + +def encode_conn_msg(msg_type: int, seq_no: int, data: bytes) -> bytes: + """编码 ConnMsg(简化接口:仅 cmd_type + seq_no + payload)""" + return encode_conn_msg_full(msg_type, "", seq_no, "", "", data) def decode_conn_msg(data: bytes) -> dict: """解码 ConnMsg → {msg_type, seq_no, data, head}(head 为完整 Head dict)""" - _dbg("decode_conn_msg", data) fdict = _parse_dict(data) head = _decode_head(_get_bytes(fdict, 1)) return {"msg_type": head["cmd_type"], "seq_no": head["seq_no"], "data": _get_bytes(fdict, 2), "head": head} +def _conn_request(cmd_type: int, cmd: str, msg_id: str, module: str, data: bytes = b"") -> bytes: + return encode_conn_msg_full(cmd_type, cmd, next_seq_no(), msg_id, module, data) + + # ============================================================ # BizMsg 层:业务 body 包装成 ConnMsg(head.cmd = method, head.module = service) # 与 conn-codec.ts buildBusinessConnMsg(cmd, module, bizData, msgId) 行为一致。 @@ -331,9 +281,7 @@ def decode_conn_msg(data: bytes) -> dict: def encode_biz_msg(service: str, method: str, req_id: str, body: bytes) -> bytes: """将已编码的业务 protobuf 包装为可直接发送的 ConnMsg bytes""" - return encode_conn_msg_full( - cmd_type=CMD_TYPE["Request"], cmd=method, seq_no=next_seq_no(), msg_id=req_id, module=service, data=body, - ) + return _conn_request(CMD_TYPE["Request"], method, req_id, service, body) def decode_biz_msg(data: bytes) -> dict: @@ -341,19 +289,14 @@ def decode_biz_msg(data: bytes) -> dict: result = decode_conn_msg(data) head = result["head"] return { - "service": head["module"], - "method": head["cmd"], - "req_id": head["msg_id"], - "body": result["data"], - "is_response": head["cmd_type"] == CMD_TYPE["Response"], - "head": head, + "service": head["module"], "method": head["cmd"], "req_id": head["msg_id"], "body": result["data"], + "is_response": head["cmd_type"] == CMD_TYPE["Response"], "head": head, } def _biz_request(method: str, prefix: str, body: bytes, msg_id: str = "") -> bytes: """biz 请求 ConnMsg;req_id 为 msg_id,空则 '_'(seq 在 conn seq_no 之前分配)""" - req_id = msg_id or f"{prefix}_{next_seq_no()}" - return encode_biz_msg(service=_BIZ_PKG, method=method, req_id=req_id, body=body) + return encode_biz_msg(_BIZ_PKG, method, msg_id or f"{prefix}_{next_seq_no()}", body) # ============================================================ @@ -388,67 +331,43 @@ def _encode_msg_content(content: dict) -> bytes: def _decode_msg_content(data: bytes) -> dict: fdict = _parse_dict(data) content = _decode_spec(fdict, _MSG_CONTENT_SPEC) - imgs = [img for img in (_decode_spec(_parse_dict(b), _IMAGE_INFO_SPEC) for b in _get_repeated_bytes(fdict, 8)) if img] + imgs = [img for img in (_decode_spec(d, _IMAGE_INFO_SPEC) for d in _parse_repeated(fdict, 8)) if img] if imgs: content["image_info_array"] = imgs - ext_map: dict[str, str] = {} - for entry_bytes in _get_repeated_bytes(fdict, 999): - efdict = _parse_dict(entry_bytes) - k = _get_string(efdict, 1) - if k: - ext_map[k] = _get_string(efdict, 2) + ext_map = {_get_string(e, 1): _get_string(e, 2) for e in _parse_repeated(fdict, 999) if _get_string(e, 1)} if ext_map: content["ext_map"] = ext_map return content # MsgBodyElement:1 msg_type (string, e.g. "TIMTextElem"), 2 msg_content (MsgContent) - - def _encode_msg_body_element(element: dict) -> bytes: buf = b"" - msg_type = element.get("msg_type", "") - if msg_type: - buf += _s(1, msg_type) - content = element.get("msg_content", {}) - if content: - buf += _m(2, _encode_msg_content(content)) + if element.get("msg_type", ""): + buf += _s(1, element["msg_type"]) + if element.get("msg_content", {}): + buf += _m(2, _encode_msg_content(element["msg_content"])) return buf def _decode_msg_body_element(data: bytes) -> dict: fdict = _parse_dict(data) content_bytes = _get_bytes(fdict, 2) - return {"msg_type": _get_string(fdict, 1, ""), "msg_content": _decode_msg_content(content_bytes) if content_bytes else {}} - - -def _encode_msg_body(fn: int, msg_body: list) -> bytes: - return b"".join(_m(fn, _encode_msg_body_element(el)) for el in msg_body) - - -def _encode_log_ext(trace_id: str) -> bytes: - """LogInfoExt:1 trace_id""" - return _s(1, trace_id) if trace_id else b"" + return {"msg_type": _get_string(fdict, 1), "msg_content": _decode_msg_content(content_bytes) if content_bytes else {}} # ============================================================ # 入站消息解析 # ============================================================ -# InboundMessagePush: 1 callback_command, 2 from_account, 3 to_account, 4 sender_nickname, -# 5 group_id, 6 group_code, 7 group_name, 8 msg_seq, 9 msg_random, 10 msg_time, 11 msg_key, -# 12 msg_id, 13 msg_body (repeated MsgBodyElement), 14 cloud_custom_data, 15 event_time, -# 16 bot_owner_id, 17 recall_msg_seq_list (repeated ImMsgSeq{1 msg_seq, 2 msg_id}), -# 18 claw_msg_type, 19 private_from_group_code, 20 log_ext (LogInfoExt{1 trace_id}) def decode_inbound_push(data: bytes) -> Optional[dict]: - """解析 InboundMessagePush biz payload。 + """解析 InboundMessagePush biz payload(字段号见 dict)。 - Returns: 上述字段的 dict(空值已过滤,msg_body / msg_seq 始终保留; - recall_msg_seq_list 为 [{msg_seq, msg_id}] 或 None),解析失败返回 None。 + 空值已过滤,msg_body / msg_seq 始终保留;recall_msg_seq_list 为 [{msg_seq, msg_id}] 或 None; + 解析失败返回 None。 """ try: - _dbg("decode_inbound_push input", data) fdict = _parse_dict(data) log_ext_bytes = _get_bytes(fdict, 20) result: dict = { @@ -469,17 +388,14 @@ def decode_inbound_push(data: bytes) -> Optional[dict]: "event_time": _get_varint(fdict, 15), "bot_owner_id": _get_string(fdict, 16), "recall_msg_seq_list": [ - {"msg_seq": _get_varint(d, 1), "msg_id": _get_string(d, 2)} - for d in map(_parse_dict, _get_repeated_bytes(fdict, 17)) + {"msg_seq": _get_varint(d, 1), "msg_id": _get_string(d, 2)} for d in _parse_repeated(fdict, 17) ] or None, "claw_msg_type": _get_varint(fdict, 18), "private_from_group_code": _get_string(fdict, 19), - "trace_id": _get_string(_parse_dict(log_ext_bytes), 1) if log_ext_bytes else "", + "trace_id": _get_string(_parse_dict(log_ext_bytes), 1) if log_ext_bytes else "", # 20 log_ext{1 trace_id} } return {k: v for k, v in result.items() if v or k in {"msg_body", "msg_seq"}} - except Exception as e: - if DEBUG_MODE: - logger.debug("[yuanbao_proto] decode_inbound_push failed: %s", e) + except Exception: return None @@ -503,49 +419,52 @@ def _decode_forward_msg_content(data: bytes) -> dict: """MsgContent → {type, text?, multimedia?}(shape 与 _format_multimedia 对齐)""" fdict = _parse_dict(data) content: dict = {"type": _get_varint(fdict, 1)} - text = _get_string(fdict, 2) - if text: - content["text"] = text - multimedia = [_decode_spec(_parse_dict(b), _FORWARD_MULTIMEDIA_SPEC) for b in _get_repeated_bytes(fdict, 3)] - if multimedia: - content["multimedia"] = multimedia + if _get_string(fdict, 2): + content["text"] = _get_string(fdict, 2) + if _get_repeated_bytes(fdict, 3): + content["multimedia"] = [_decode_spec(d, _FORWARD_MULTIMEDIA_SPEC) for d in _parse_repeated(fdict, 3)] return content -def _decode_forward_msg(data: bytes) -> dict: - fdict = _parse_dict(data) +def _decode_forward_msg(fdict: dict) -> dict: return { - "sender": _get_string(fdict, 1), - "time": _get_varint(fdict, 2), - "plainText": _get_string(fdict, 3), + "sender": _get_string(fdict, 1), "time": _get_varint(fdict, 2), "plainText": _get_string(fdict, 3), "msgContent": [_decode_forward_msg_content(b) for b in _get_repeated_bytes(fdict, 4)], } def decode_forward_msg_data(data: bytes) -> Optional[dict]: - """Parse ForwardMsgData protobuf bytes (the base64-decoded ext_map value). - - Returns the ``sub_type`` / ``nick_name`` / ``msg`` structure consumed by - ``ForwardedRecordsParseMiddleware.build_forward_text``; ``None`` on parse failure. - """ + """Parse ForwardMsgData bytes (base64-decoded ext_map value) into the {sub_type, nick_name, msg, ...} + structure consumed by ForwardedRecordsParseMiddleware.build_forward_text; None on parse failure.""" try: fdict = _parse_dict(data) return { - "sub_type": _get_varint(fdict, 1), - "begin_time": _get_varint(fdict, 2), - "end_time": _get_varint(fdict, 3), - "nick_name": _get_string(fdict, 4), - "msg": [_decode_forward_msg(b) for b in _get_repeated_bytes(fdict, 5)], + "sub_type": _get_varint(fdict, 1), "begin_time": _get_varint(fdict, 2), "end_time": _get_varint(fdict, 3), + "nick_name": _get_string(fdict, 4), "msg": [_decode_forward_msg(d) for d in _parse_repeated(fdict, 5)], } - except Exception as e: - if DEBUG_MODE: - logger.debug("[yuanbao_proto] decode_forward_msg_data failed: %s", e) + except Exception: return None # ============================================================ # Outbound message encoding # ============================================================ +# 发送请求字段表 [(field_number, kind, value)];kind: +# "S" string 总是编码 "s" string 非空才编码 "v" varint 非零才编码 "n" varint 非 None 才编码 +# "b" repeated MsgBodyElement "t" LogInfoExt{1 trace_id} 非空才编码 +_SEND_ENCODERS = { + "S": _s, "s": _s, "v": _v, "n": _v, + "b": lambda fn, body: b"".join(_m(fn, _encode_msg_body_element(el)) for el in body), + "t": lambda fn, trace_id: _m(fn, _s(1, trace_id)), +} + + +def _send_request(method: str, prefix: str, msg_id: str, parts: list) -> bytes: + buf = b"" + for fn, kind, val in parts: + if kind == "S" or (val is not None if kind == "n" else val): + buf += _SEND_ENCODERS[kind](fn, val) + return _biz_request(method, prefix, buf, msg_id) def encode_send_c2c_message( @@ -558,30 +477,15 @@ def encode_send_c2c_message( group_code: str = "", trace_id: str = "", ) -> bytes: - """Encode a SendC2CMessageReq and return the full ConnMsg bytes (ready to send over WebSocket). + """SendC2CMessageReq → 完整 ConnMsg bytes(可直接发送)。 - SendC2CMessageReq: 1 msg_id, 2 to_account, 3 from_account, 4 msg_random, - 5 msg_body (repeated MsgBodyElement), 6 group_code, 7 msg_seq, 8 log_ext. msg_body items are {"msg_type": str, "msg_content": dict}; msg_id doubles as req_id when set; group_code is filled for the "private chat originating from a group" case. """ - biz_bytes = b"" - if msg_id: - biz_bytes += _s(1, msg_id) - biz_bytes += _s(2, to_account) - if from_account: - biz_bytes += _s(3, from_account) - if msg_random: - biz_bytes += _v(4, msg_random) - biz_bytes += _encode_msg_body(5, msg_body) - if group_code: - biz_bytes += _s(6, group_code) - if msg_seq is not None: - biz_bytes += _v(7, msg_seq) - if trace_id: - biz_bytes += _m(8, _encode_log_ext(trace_id)) - _dbg("encode_send_c2c biz payload", biz_bytes) - return _biz_request("send_c2c_message", "c2c", biz_bytes, msg_id) + return _send_request("send_c2c_message", "c2c", msg_id, [ + (1, "s", msg_id), (2, "S", to_account), (3, "s", from_account), (4, "v", msg_random), + (5, "b", msg_body), (6, "s", group_code), (7, "n", msg_seq), (8, "t", trace_id), + ]) def encode_send_group_message( @@ -595,30 +499,11 @@ def encode_send_group_message( ref_msg_id: str = "", trace_id: str = "", ) -> bytes: - """Encode a SendGroupMessageReq and return the full ConnMsg bytes (ready to send over WebSocket). - - SendGroupMessageReq: 1 msg_id, 2 group_code, 3 from_account, 4 to_account (usually empty), - 5 random (string), 6 msg_body, 7 ref_msg_id (quoted message), 8 msg_seq, 9 log_ext. - """ - biz_bytes = b"" - if msg_id: - biz_bytes += _s(1, msg_id) - biz_bytes += _s(2, group_code) - if from_account: - biz_bytes += _s(3, from_account) - if to_account: - biz_bytes += _s(4, to_account) - if random: - biz_bytes += _s(5, random) - biz_bytes += _encode_msg_body(6, msg_body) - if ref_msg_id: - biz_bytes += _s(7, ref_msg_id) - if msg_seq is not None: - biz_bytes += _v(8, msg_seq) - if trace_id: - biz_bytes += _m(9, _encode_log_ext(trace_id)) - _dbg("encode_send_group biz payload", biz_bytes) - return _biz_request("send_group_message", "grp", biz_bytes, msg_id) + """SendGroupMessageReq → 完整 ConnMsg bytes。to_account usually empty; ref_msg_id = quoted message.""" + return _send_request("send_group_message", "grp", msg_id, [ + (1, "s", msg_id), (2, "S", group_code), (3, "s", from_account), (4, "s", to_account), (5, "s", random), + (6, "b", msg_body), (7, "s", ref_msg_id), (8, "n", msg_seq), (9, "t", trace_id), + ]) # ============================================================ @@ -637,13 +522,12 @@ def encode_auth_bind( bot_version: str = "", route_env: str = "", ) -> bytes: - """构造 auth-bind 请求 ConnMsg bytes。 + """auth-bind 请求 ConnMsg bytes。 AuthBindReq: 1 biz_id, 2 auth_info (AuthInfo{1 uid, 2 source, 3 token}), 3 device_info (DeviceInfo{1 app_version, 2 app_operation_system, 10 instance_id, 24 bot_version}), 5 env_name """ - auth_buf = _s(1, uid) + _s(2, source) + _s(3, token) dev_buf = b"" if app_version: dev_buf += _s(1, app_version) @@ -652,28 +536,21 @@ def encode_auth_bind( dev_buf += _s(10, str(HERMES_INSTANCE_ID)) if bot_version: dev_buf += _s(24, bot_version) - req_buf = _s(1, biz_id) + _m(2, auth_buf) + _m(3, dev_buf) + req_buf = _s(1, biz_id) + _m(2, _s(1, uid) + _s(2, source) + _s(3, token)) + _m(3, dev_buf) if route_env: req_buf += _s(5, route_env) - return encode_conn_msg_full( - cmd_type=CMD_TYPE["Request"], cmd=CMD["AuthBind"], seq_no=next_seq_no(), msg_id=msg_id, - module=MODULE["ConnAccess"], data=req_buf, - ) + return _conn_request(CMD_TYPE["Request"], CMD["AuthBind"], msg_id, MODULE["ConnAccess"], req_buf) def encode_ping(msg_id: str) -> bytes: - """构造 ping 请求 ConnMsg bytes(PingReq 为空消息)""" - return encode_conn_msg_full( - cmd_type=CMD_TYPE["Request"], cmd=CMD["Ping"], seq_no=next_seq_no(), msg_id=msg_id, - module=MODULE["ConnAccess"], data=b"", - ) + """ping 请求 ConnMsg bytes(PingReq 为空消息)""" + return _conn_request(CMD_TYPE["Request"], CMD["Ping"], msg_id, MODULE["ConnAccess"]) def encode_push_ack(original_head: dict) -> bytes: - """构造 push ACK 回包(回显原 head 的 cmd / msg_id / module)""" - return encode_conn_msg_full( - cmd_type=CMD_TYPE["PushAck"], cmd=original_head.get("cmd", ""), seq_no=next_seq_no(), - msg_id=original_head.get("msg_id", ""), module=original_head.get("module", ""), data=b"", + """push ACK 回包(回显原 head 的 cmd / msg_id / module)""" + return _conn_request( + CMD_TYPE["PushAck"], original_head.get("cmd", ""), original_head.get("msg_id", ""), original_head.get("module", ""), ) @@ -684,8 +561,7 @@ def encode_push_ack(original_head: dict) -> bytes: def encode_send_private_heartbeat(from_account: str, to_account: str, heartbeat: int = WS_HEARTBEAT_RUNNING) -> bytes: """SendPrivateHeartbeatReq{1 from_account, 2 to_account, 3 heartbeat (RUNNING=1, FINISH=2)} → ConnMsg bytes""" - buf = _s(1, from_account) + _s(2, to_account) + _v(3, heartbeat) - return _biz_request("send_private_heartbeat", "hb_priv", buf) + return _biz_request("send_private_heartbeat", "hb_priv", _s(1, from_account) + _s(2, to_account) + _v(3, heartbeat)) def encode_send_group_heartbeat( @@ -693,8 +569,7 @@ def encode_send_group_heartbeat( ) -> bytes: """SendGroupHeartbeatReq{1 from_account, 2 to_account (群场景留空), 3 group_code, 4 send_time (ms; 0 → now), 5 heartbeat} → ConnMsg bytes""" - import time as _time - ts = send_time or int(_time.time() * 1000) + ts = send_time or int(time.time() * 1000) buf = _s(1, from_account) + _s(2, "") + _s(3, group_code) + _v(4, ts) + _v(5, heartbeat) return _biz_request("send_group_heartbeat", "hb_grp", buf) @@ -705,26 +580,22 @@ def encode_query_group_info(group_code: str) -> bytes: def decode_query_group_info_rsp(data: bytes) -> Optional[dict]: - """解码 QueryGroupInfoRsp(对齐 TS biz-codec / member.ts queryGroupInfo)。 - - QueryGroupInfoRsp{1 code, 2 message, 3 GroupInfo{1 group_name, 2 group_owner_user_id, - 3 group_owner_nickname, 4 group_size}} → {code, message?, group_name, owner_id, - owner_nickname, member_count},解析失败返回 None。 - """ + """QueryGroupInfoRsp{1 code, 2 message, 3 GroupInfo{1 group_name, 2 group_owner_user_id, + 3 group_owner_nickname, 4 group_size}} → {code, message?, group_name, owner_id, owner_nickname, + member_count}(对齐 TS member.ts queryGroupInfo);解析失败返回 None。""" try: fdict = _parse_dict(data) - result: dict = {"code": _get_varint(fdict, 1, 0)} - msg = _get_string(fdict, 2) - if msg: - result["message"] = msg + result: dict = {"code": _get_varint(fdict, 1)} + if _get_string(fdict, 2): + result["message"] = _get_string(fdict, 2) # field 3 taken regardless of wire type; non-bytes payloads fall back to defaults gi_entries = fdict.get(3, []) gi_bytes = gi_entries[0][1] if gi_entries else b"" gi = _parse_dict(gi_bytes) if gi_bytes and isinstance(gi_bytes, (bytes, bytearray)) else {} - result["group_name"] = _get_string(gi, 1) - result["owner_id"] = _get_string(gi, 2) - result["owner_nickname"] = _get_string(gi, 3) - result["member_count"] = _get_varint(gi, 4, 0) + result.update( + group_name=_get_string(gi, 1), owner_id=_get_string(gi, 2), owner_nickname=_get_string(gi, 3), + member_count=_get_varint(gi, 4), + ) return result except Exception: return None @@ -732,36 +603,25 @@ def decode_query_group_info_rsp(data: bytes) -> Optional[dict]: def encode_get_group_member_list(group_code: str, offset: int = 0, limit: int = 200) -> bytes: """GetGroupMemberListReq{1 group_code, 2 offset, 3 limit} → ConnMsg bytes""" - buf = _s(1, group_code) - if offset: - buf += _v(2, offset) - buf += _v(3, limit) - return _biz_request("get_group_member_list", "gml", buf) + return _biz_request("get_group_member_list", "gml", _s(1, group_code) + (_v(2, offset) if offset else b"") + _v(3, limit)) def decode_get_group_member_list_rsp(data: bytes) -> Optional[dict]: - """解码 GetGroupMemberListRsp{1 code, 2 message, 3 members (repeated MemberInfo), 4 next_offset, - 5 is_complete};MemberInfo{1 user_id, 2 nickname, 3 role (0=member,1=admin,2=owner), - 4 join_time, 5 name_card (群昵称)}。member dict 过滤空值但保留 role;解析失败返回 None。 - """ + """GetGroupMemberListRsp{1 code, 2 message, 3 members (repeated MemberInfo), 4 next_offset, 5 is_complete}; + MemberInfo{1 user_id, 2 nickname, 3 role (0=member,1=admin,2=owner), 4 join_time, 5 name_card (群昵称)}。 + member dict 过滤空值但保留 role;解析失败返回 None。""" try: fdict = _parse_dict(data) members = [] - for mdict in map(_parse_dict, _get_repeated_bytes(fdict, 3)): + for m in _parse_repeated(fdict, 3): member = { - "user_id": _get_string(mdict, 1), - "nickname": _get_string(mdict, 2), - "role": _get_varint(mdict, 3), - "join_time": _get_varint(mdict, 4), - "name_card": _get_string(mdict, 5), + "user_id": _get_string(m, 1), "nickname": _get_string(m, 2), "role": _get_varint(m, 3), + "join_time": _get_varint(m, 4), "name_card": _get_string(m, 5), } members.append({k: v for k, v in member.items() if v or k == "role"}) return { - "code": _get_varint(fdict, 1, 0), - "message": _get_string(fdict, 2), - "members": members, - "next_offset": _get_varint(fdict, 4), - "is_complete": bool(_get_varint(fdict, 5)), + "code": _get_varint(fdict, 1), "message": _get_string(fdict, 2), "members": members, + "next_offset": _get_varint(fdict, 4), "is_complete": bool(_get_varint(fdict, 5)), } except Exception: return None From 11769237b1b90f6abef3aa5eea3e4ec0e5bd94df Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:12:40 -0700 Subject: [PATCH 02/10] refactor(gateway/yuanbao-media): compact mime tables, image-size parsers, COS sign/upload; drop COS_USE_ACCELERATE constant-fold --- gateway/platforms/yuanbao_media.py | 198 +++++++++-------------------- 1 file changed, 61 insertions(+), 137 deletions(-) diff --git a/gateway/platforms/yuanbao_media.py b/gateway/platforms/yuanbao_media.py index af5da3a680..e422041c45 100644 --- a/gateway/platforms/yuanbao_media.py +++ b/gateway/platforms/yuanbao_media.py @@ -1,14 +1,8 @@ """ -yuanbao_media.py — 元宝平台媒体处理模块 - -提供 COS 上传、文件下载、TIM 媒体消息构建等功能。移植自 TypeScript 版 media.ts -(yuanbao-openclaw-plugin),使用 httpx 替代 cos-nodejs-sdk-v5。 - -COS 上传流程: - 1. genUploadInfo 获取临时凭证(tmpSecretId/tmpSecretKey/sessionToken) - 2. 临时凭证 HMAC-SHA1 签名构建 Authorization 头 - 3. HTTP PUT 上传到 COS +yuanbao_media.py — 元宝平台媒体处理:COS 上传、URL 下载(SSRF 防护)、TIM 媒体消息体。 +移植自 TypeScript 版 media.ts(yuanbao-openclaw-plugin),用 httpx 替代 cos-nodejs-sdk-v5。 +COS 上传流程:genUploadInfo 获取临时凭证 → 临时凭证 HMAC-SHA1 签名 Authorization 头 → HTTP PUT。 TIM 消息体:build_image_msg_body() → TIMImageElem,build_file_msg_body() → TIMFileElem """ @@ -32,9 +26,6 @@ UPLOAD_INFO_PATH = "/api/resource/genUploadInfo" DEFAULT_API_DOMAIN = "yuanbao.tencent.com" DEFAULT_MAX_SIZE_MB = 50 -# COS 加速域名(优先使用全球加速) -COS_USE_ACCELERATE = True - # MIME → image_format 数字(TIM 协议字段) _MIME_TO_IMAGE_FORMAT: dict[str, int] = { "image/jpeg": 1, "image/jpg": 1, "image/gif": 2, "image/png": 3, "image/bmp": 4, @@ -42,15 +33,8 @@ _MIME_TO_IMAGE_FORMAT: dict[str, int] = { } _EXT_TO_MIME: dict[str, str] = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - ".heic": "image/heic", - ".tiff": "image/tiff", - ".ico": "image/x-icon", + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", + ".bmp": "image/bmp", ".heic": "image/heic", ".tiff": "image/tiff", ".ico": "image/x-icon", ".pdf": "application/pdf", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", @@ -58,31 +42,27 @@ _EXT_TO_MIME: dict[str, str] = { ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".ppt": "application/vnd.ms-powerpoint", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ".txt": "text/plain", - ".zip": "application/zip", - ".tar": "application/x-tar", - ".gz": "application/gzip", - ".mp3": "audio/mpeg", - ".mp4": "video/mp4", - ".wav": "audio/wav", - ".ogg": "audio/ogg", - ".webm": "video/webm", + ".txt": "text/plain", ".zip": "application/zip", ".tar": "application/x-tar", ".gz": "application/gzip", + ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".wav": "audio/wav", ".ogg": "audio/ogg", ".webm": "video/webm", } _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".tiff", ".ico"} +def _ext(filename: str) -> str: + return os.path.splitext(filename)[-1].lower() + + def guess_mime_type(filename: str) -> str: - """根据文件扩展名猜测 MIME 类型。""" - return _EXT_TO_MIME.get(os.path.splitext(filename)[-1].lower(), "application/octet-stream") + return _EXT_TO_MIME.get(_ext(filename), "application/octet-stream") def is_image(filename: str, mime_type: str = "") -> bool: - """判断是否为图片类型(MIME 前缀或扩展名)。""" - return mime_type.startswith("image/") or os.path.splitext(filename)[-1].lower() in _IMAGE_EXTS + """MIME 前缀或扩展名判断是否为图片。""" + return mime_type.startswith("image/") or _ext(filename) in _IMAGE_EXTS def get_image_format(mime_type: str) -> int: - """获取 TIM 图片格式编号。""" + """TIM 图片格式编号(未知 → 255)。""" return _MIME_TO_IMAGE_FORMAT.get(mime_type.lower(), 255) @@ -97,11 +77,6 @@ def generate_file_id() -> str: # ============ 图片尺寸解析(纯 Python,无需 Pillow) ============ -def parse_image_size(data: bytes) -> Optional[dict[str, int]]: - """解析图片宽高(JPEG/PNG/GIF/WebP),返回 {"width", "height"} 或 None。""" - return _parse_png_size(data) or _parse_jpeg_size(data) or _parse_gif_size(data) or _parse_webp_size(data) - - def _wh(w: int, h: int) -> dict[str, int]: return {"width": w, "height": h} @@ -113,17 +88,16 @@ def _parse_png_size(buf: bytes) -> Optional[dict[str, int]]: def _parse_jpeg_size(buf: bytes) -> Optional[dict[str, int]]: - if len(buf) < 4 or buf[0] != 0xFF or buf[1] != 0xD8: + if len(buf) < 4 or buf[:2] != b"\xff\xd8": return None i = 2 while i < len(buf) - 9: if buf[i] != 0xFF: i += 1 - continue - if buf[i + 1] in {0xC0, 0xC2}: # SOF0 / SOF2: height then width + elif buf[i + 1] in {0xC0, 0xC2}: # SOF0 / SOF2: height then width h, w = struct.unpack(">HH", buf[i + 5: i + 9]) return _wh(w, h) - if i + 3 < len(buf): + elif i + 3 < len(buf): i += 2 + struct.unpack(">H", buf[i + 2: i + 4])[0] else: break @@ -131,7 +105,7 @@ def _parse_jpeg_size(buf: bytes) -> Optional[dict[str, int]]: def _parse_gif_size(buf: bytes) -> Optional[dict[str, int]]: - if len(buf) < 10 or buf[:6].decode("ascii", errors="replace") not in {"GIF87a", "GIF89a"}: + if len(buf) < 10 or buf[:6] not in (b"GIF87a", b"GIF89a"): return None return _wh(*struct.unpack(" Optional[dict[str, int]]: def _parse_webp_size(buf: bytes) -> Optional[dict[str, int]]: if len(buf) < 16 or buf[:4] != b"RIFF" or buf[8:12] != b"WEBP": return None - chunk = buf[12:16].decode("ascii", errors="replace") - if chunk == "VP8 ": - if len(buf) >= 30 and buf[23:26] == b"\x9d\x01\x2a": - w, h = struct.unpack("= 25 and buf[20] == 0x2F: - bits = struct.unpack("> 14) & 0x3FFF) + 1) - elif chunk == "VP8X" and len(buf) >= 30: + chunk = buf[12:16] + if chunk == b"VP8 " and len(buf) >= 30 and buf[23:26] == b"\x9d\x01\x2a": + w, h = struct.unpack("= 25 and buf[20] == 0x2F: + bits = struct.unpack("> 14) & 0x3FFF) + 1) + if chunk == b"VP8X" and len(buf) >= 30: return _wh(int.from_bytes(buf[24:27], "little") + 1, int.from_bytes(buf[27:30], "little") + 1) return None +def parse_image_size(data: bytes) -> Optional[dict[str, int]]: + """解析图片宽高(JPEG/PNG/GIF/WebP),返回 {"width", "height"} 或 None。""" + return _parse_png_size(data) or _parse_jpeg_size(data) or _parse_gif_size(data) or _parse_webp_size(data) + + # ============ URL 下载 ============ async def download_url(url: str, max_size_mb: int = DEFAULT_MAX_SIZE_MB) -> tuple[bytes, str]: @@ -180,17 +157,14 @@ async def download_url(url: str, max_size_mb: int = DEFAULT_MAX_SIZE_MB) -> tupl async with create_ssrf_safe_async_client( timeout=30.0, follow_redirects=True, event_hooks={"response": [_redirect_guard]}, ) as client: - # HEAD 预检大小;部分服务器不支持 HEAD,忽略 - try: + try: # HEAD 预检大小;部分服务器不支持 HEAD,忽略 head = await client.head(url) content_length = int(head.headers.get("content-length", 0) or 0) if content_length > 0 and content_length > max_bytes: raise ValueError(f"文件过大: {content_length / 1024 / 1024:.1f} MB > {max_size_mb} MB") except httpx.HTTPStatusError: pass - - # 流式 GET,防止超限 - async with client.stream("GET", url) as resp: + async with client.stream("GET", url) as resp: # 流式 GET,防止超限 resp.raise_for_status() content_type = resp.headers.get("content-type", "").split(";")[0].strip() chunks: list[bytes] = [] @@ -224,40 +198,28 @@ def _cos_sign( start_time: Optional[int] = None, expire_seconds: int = 3600, ) -> str: - """构建 COS 请求签名(q-sign-algorithm=sha1;https://cloud.tencent.com/document/product/436/7778)。 + """COS Authorization 头(q-sign-algorithm=sha1;https://cloud.tencent.com/document/product/436/7778)。 - Args: - method / path: HTTP 方法(小写)与 URL-encoded 路径 - params / headers: 参与签名的查询参数与请求头(key 会被小写) - secret_id / secret_key: 临时 tmpSecretId / tmpSecretKey - start_time: 签名起始 Unix 时间戳(默认 now);expire_seconds: 有效期(秒) - - Returns: - Authorization header 值 + method 小写、path 已 URL-encode;params / headers 的 key 会被小写;secret_* 为临时 tmpSecretId/Key; + start_time 默认 now。 """ - now = int(time.time()) - q_sign_time = f"{start_time or now};{(start_time or now) + expire_seconds}" - # SignKey = HMAC-SHA1(SecretKey, q-sign-time) - sign_key = _hmac_sha1_hex(secret_key, q_sign_time) + start = start_time or int(time.time()) + q_sign_time = f"{start};{start + expire_seconds}" + sign_key = _hmac_sha1_hex(secret_key, q_sign_time) # SignKey = HMAC-SHA1(SecretKey, q-sign-time) sorted_params = _sorted_kv(params) sorted_headers = _sorted_kv(headers) http_string = "\n".join([ - method.lower(), - path, + method.lower(), path, "&".join(f"{k}={v}" for k, v in sorted_params), "&".join(f"{k}={v}" for k, v in sorted_headers), "", ]) string_to_sign = "\n".join(["sha1", q_sign_time, hashlib.sha1(http_string.encode("utf-8")).hexdigest(), ""]) - signature = _hmac_sha1_hex(sign_key, string_to_sign) return ( - f"q-sign-algorithm=sha1" - f"&q-ak={secret_id}" - f"&q-sign-time={q_sign_time}" - f"&q-key-time={q_sign_time}" + f"q-sign-algorithm=sha1&q-ak={secret_id}&q-sign-time={q_sign_time}&q-key-time={q_sign_time}" f"&q-header-list={';'.join(k for k, _ in sorted_headers)}" f"&q-url-param-list={';'.join(k for k, _ in sorted_params)}" - f"&q-signature={signature}" + f"&q-signature={_hmac_sha1_hex(sign_key, string_to_sign)}" ) @@ -274,30 +236,19 @@ async def get_cos_credentials( ) -> dict: """调用 genUploadInfo 获取 COS 临时密钥及上传配置。 - Args: - app_key / bot_id: X-ID 头(bot_id 优先); api_domain: 如 https://bot.yuanbao.tencent.com - token: 当前有效签票 token(X-Token 头); file_id: 不传则自动生成 - - Returns: - 接口 data dict:bucketName, region, location (COS key), encryptTmpSecretId, - encryptTmpSecretKey, encryptToken, startTime, expiredTime, resourceUrl, resourceID(可选) - - Raises: - RuntimeError: 接口返回非 0 code 或 bucketName/location 缺失 + X-ID 头取 bot_id(优先)或 app_key;api_domain 如 https://bot.yuanbao.tencent.com;file_id 不传则自动生成。 + 返回接口 data dict:bucketName, region, location (COS key), encryptTmpSecretId, encryptTmpSecretKey, + encryptToken, startTime, expiredTime, resourceUrl, resourceID(可选)。 + Raises RuntimeError: 接口返回非 0 code 或 bucketName/location 缺失 """ - if file_id is None: - file_id = generate_file_id() - upload_url = f"{api_domain.rstrip('/')}{UPLOAD_INFO_PATH}" headers = {"Content-Type": "application/json", "X-Token": token, "X-ID": bot_id or app_key, "X-Source": "web"} if route_env: headers["X-Route-Env"] = route_env - body = {"fileName": filename, "fileId": file_id, "docFrom": "localDoc", "docOpenId": ""} - + body = {"fileName": filename, "fileId": file_id if file_id is not None else generate_file_id(), "docFrom": "localDoc", "docOpenId": ""} async with httpx.AsyncClient(timeout=15.0) as client: - resp = await client.post(upload_url, json=body, headers=headers) + resp = await client.post(f"{api_domain.rstrip('/')}{UPLOAD_INFO_PATH}", json=body, headers=headers) resp.raise_for_status() result: dict[str, Any] = resp.json() - code = result.get("code") if code != 0 and code is not None: raise RuntimeError(f"genUploadInfo 失败: code={code}, msg={result.get('msg', '')}") @@ -316,68 +267,43 @@ async def upload_to_cos( bucket: str, region: str, ) -> dict: - """用临时凭证 HMAC-SHA1 签名,httpx PUT 上传到 COS。 + """用临时凭证(get_cos_credentials() 返回的 dict)HMAC-SHA1 签名,httpx PUT 上传到 COS(走全球加速域名)。 - Args: - credentials: get_cos_credentials() 返回的 dict(encryptTmpSecretId/Key, encryptToken, - location, resourceUrl, startTime, expiredTime) - bucket / region: 如 chatbot-1234567890 / ap-guangzhou - - Returns: - {url, uuid (内容 MD5), size, width?, height? (仅图片)} - - Raises: - httpx.HTTPStatusError: COS 返回非 2xx - RuntimeError: credentials 字段缺失 + Returns {url, uuid (内容 MD5), size, width?, height? (仅图片)} + Raises httpx.HTTPStatusError(COS 非 2xx)/ RuntimeError(credentials 字段缺失) """ secret_id: str = credentials.get("encryptTmpSecretId", "") secret_key: str = credentials.get("encryptTmpSecretKey", "") session_token: str = credentials.get("encryptToken", "") cos_key: str = credentials.get("location", "") - resource_url: str = credentials.get("resourceUrl", "") start_time: Optional[int] = credentials.get("startTime") expired_time: Optional[int] = credentials.get("expiredTime") - if not secret_id or not secret_key or not cos_key: raise RuntimeError( - f"COS credentials 不完整: secretId={bool(secret_id)}, " - f"secretKey={bool(secret_key)}, location={bool(cos_key)}" + f"COS credentials 不完整: secretId={bool(secret_id)}, secretKey={bool(secret_key)}, location={bool(cos_key)}" ) - - cos_host = f"{bucket}.cos.accelerate.myqcloud.com" if COS_USE_ACCELERATE else f"{bucket}.cos.{region}.myqcloud.com" + cos_host = f"{bucket}.cos.accelerate.myqcloud.com" encoded_key = urllib.parse.quote(cos_key, safe="/").lstrip("/") cos_url = f"https://{cos_host}/{encoded_key}" - if not content_type or content_type == "application/octet-stream": content_type = guess_mime_type(filename) if is_image(filename) else "application/octet-stream" - - file_uuid = md5_hex(file_bytes) file_size = len(file_bytes) - now = int(time.time()) authorization = _cos_sign( - method="put", - path=f"/{encoded_key}", - params={}, + method="put", path=f"/{encoded_key}", params={}, headers={"host": cos_host, "content-type": content_type, "x-cos-security-token": session_token}, - secret_id=secret_id, - secret_key=secret_key, + secret_id=secret_id, secret_key=secret_key, start_time=start_time if start_time else now, expire_seconds=(expired_time - now) if expired_time and expired_time > now else 3600, ) put_headers = {"Authorization": authorization, "Content-Type": content_type, "x-cos-security-token": session_token} - logger.info("COS PUT: bucket=%s region=%s key=%s size=%d mime=%s", bucket, region, cos_key, file_size, content_type) async with httpx.AsyncClient(timeout=120.0) as client: resp = await client.put(cos_url, content=file_bytes, headers=put_headers) resp.raise_for_status() - - result: dict[str, Any] = {"url": resource_url or cos_url, "uuid": file_uuid, "size": file_size} + result: dict[str, Any] = {"url": credentials.get("resourceUrl", "") or cos_url, "uuid": md5_hex(file_bytes), "size": file_size} if content_type.startswith("image/"): - size_info = parse_image_size(file_bytes) - if size_info: - result["width"] = size_info["width"] - result["height"] = size_info["height"] + result.update(parse_image_size(file_bytes) or {}) logger.info("COS 上传成功: url=%s size=%d", result["url"], file_size) return result @@ -393,21 +319,19 @@ def build_image_msg_body( height: int = 0, mime_type: str = "", ) -> list[dict]: - """构建 TIMImageElem 消息体(可直接放入 msg_body)。uuid 缺省依次退到 filename / URL basename / "image"。""" + """TIMImageElem 消息体(可直接放入 msg_body)。uuid 缺省依次退到 filename / URL basename / "image"。""" return [{ "msg_type": "TIMImageElem", "msg_content": { "uuid": uuid or filename or _basename_from_url(url) or "image", "image_format": get_image_format(mime_type) if mime_type else 255, - "image_info_array": [ - {"type": 1, "size": size, "width": width, "height": height, "url": url}, # type 1 = 原图 - ], + "image_info_array": [{"type": 1, "size": size, "width": width, "height": height, "url": url}], # type 1 = 原图 }, }] def build_file_msg_body(url: str, filename: str, uuid: Optional[str] = None, size: int = 0) -> list[dict]: - """构建 TIMFileElem 消息体(可直接放入 msg_body)。uuid 缺省使用 filename。""" + """TIMFileElem 消息体(可直接放入 msg_body)。uuid 缺省使用 filename。""" return [{ "msg_type": "TIMFileElem", "msg_content": {"uuid": uuid or filename, "file_name": filename, "file_size": size, "url": url}, From 438628b98bf2b36c6bab704137a3e9f42cf78a21 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:14:17 -0700 Subject: [PATCH 03/10] refactor(gateway/whatsapp): compact WhatsAppBehaviorMixin bodies; fold format_message closures into module helpers --- gateway/platforms/whatsapp_common.py | 206 ++++++++++----------------- 1 file changed, 77 insertions(+), 129 deletions(-) diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py index 29e14945eb..38cc71d632 100644 --- a/gateway/platforms/whatsapp_common.py +++ b/gateway/platforms/whatsapp_common.py @@ -3,20 +3,10 @@ Transport-agnostic WhatsApp behavior shared by the Baileys bridge adapter and th Cloud API adapter: allow-list / DM / group gating, mention detection, quoted-reply- to-bot detection, broadcast filtering, WhatsApp markdown conversion, chunk budgeting. -Mixin contract — the adapter must set these on ``self`` before any of the -mixin's methods are called (typically in ``__init__``): - - self.config # gateway.config.PlatformConfig - self.name # str — adapter name (used in log lines) - self._dm_policy # str: "open" | "allowlist" | "disabled" - self._allow_from # set[str] - self._group_policy # str: "open" | "allowlist" | "disabled" - self._group_allow_from # set[str] - self._mention_patterns # list[re.Pattern] - self._reply_prefix # Optional[str] - -Class attributes ``MAX_MESSAGE_LENGTH`` and ``DEFAULT_REPLY_PREFIX`` are -defined on the mixin and may be overridden per-adapter if needed. +Mixin contract — the host adapter sets these on ``self`` before calling any mixin +method: ``config`` (PlatformConfig), ``name``, ``_dm_policy`` / ``_group_policy`` +("open" | "allowlist" | "disabled"), ``_allow_from`` / ``_group_allow_from`` (set[str]), +``_mention_patterns`` (list[re.Pattern]), ``_reply_prefix`` (Optional[str]). """ from __future__ import annotations @@ -33,6 +23,27 @@ from gateway.platforms._shared import get_scoped_secret as _get_wsecret logger = logging.getLogger(__name__) _TRUTHY = {"true", "1", "yes", "on"} +_OPTIN_TRUTHY = {"true", "1", "yes"} + + +def _stash(pattern: str, text: str, tag: str) -> tuple[str, list[str]]: + """Replace every ``pattern`` match with a ``\\x00\\x00`` placeholder.""" + saved: list[str] = [] + + def keep(m: re.Match) -> str: + saved.append(m.group(0)) + return f"\x00{tag}{len(saved) - 1}\x00" + + return re.sub(pattern, keep, text), saved + + +def _header_to_bold(m: re.Match) -> str: + """``# Header`` → ``*Header*``, stripping already-bolded ``*...*`` so ``# **Title**`` + doesn't render with literal asterisks.""" + inner = m.group(1).strip() + while len(inner) > 1 and inner.startswith("*") and inner.endswith("*"): + inner = inner[1:-1].strip() + return f"*{inner}*" class WhatsAppBehaviorMixin: @@ -54,8 +65,7 @@ class WhatsAppBehaviorMixin: spaces — WhatsApp renders them as mojibake prefixes. Emoji joiners are kept.""" if not content: return content - content = cls._OUTBOUND_INVISIBLE_CHARS_RE.sub("", content) - return cls._OUTBOUND_ODD_SPACE_RE.sub(" ", content) + return cls._OUTBOUND_ODD_SPACE_RE.sub(" ", cls._OUTBOUND_INVISIBLE_CHARS_RE.sub("", content)) @property def enforces_own_access_policy(self) -> bool: @@ -64,8 +74,7 @@ class WhatsAppBehaviorMixin: def _effective_reply_prefix(self) -> str: """Prefix for outgoing replies in self-chat mode (Cloud API overrides to ``""``).""" - whatsapp_mode = _get_wsecret("WHATSAPP_MODE", default="self-chat") or "self-chat" - if whatsapp_mode != "self-chat": + if (_get_wsecret("WHATSAPP_MODE", default="self-chat") or "self-chat") != "self-chat": return "" if self._reply_prefix is not None: return self._reply_prefix.replace("\\n", "\n") @@ -80,11 +89,11 @@ class WhatsAppBehaviorMixin: def _whatsapp_require_mention(self) -> bool: configured = self.config.extra.get("require_mention") - if configured is not None: - if isinstance(configured, str): - return configured.lower() in _TRUTHY - return bool(configured) - return (_get_wsecret("WHATSAPP_REQUIRE_MENTION", default="false") or "false").lower() in _TRUTHY + if configured is None: + configured = _get_wsecret("WHATSAPP_REQUIRE_MENTION", default="false") or "false" + if isinstance(configured, str): + return configured.lower() in _TRUTHY + return bool(configured) def _whatsapp_free_response_chats(self) -> set[str]: raw = self.config.extra.get("free_response_chats") @@ -97,9 +106,8 @@ class WhatsAppBehaviorMixin: """Parse allow_from / group_allow_from from config (list) or env var (CSV).""" if raw is None: return set() - if isinstance(raw, list): - return {str(part).strip() for part in raw if str(part).strip()} - return {part.strip() for part in str(raw).split(",") if part.strip()} + parts = raw if isinstance(raw, list) else str(raw).split(",") + return {str(part).strip() for part in parts if str(part).strip()} def _select_dm_allowlist(self, extra: Dict[str, Any], env_keys, read_env) -> Any: """Pick the raw DM allowlist by key *presence*: ``allow_from``/``allowFrom`` in @@ -127,9 +135,7 @@ class WhatsAppBehaviorMixin: """ source = getattr(self, "_dm_allowlist_source", None) if isinstance(source, str) and source != "config": - if source in os.environ: - return self._coerce_allow_list(os.environ.get(source, "")) - return set() + return self._coerce_allow_list(os.environ[source]) if source in os.environ else set() return set(self._allow_from or ()) # ------------------------------------------------------------------ JID helpers @@ -146,16 +152,14 @@ class WhatsAppBehaviorMixin: def _is_broadcast_chat(chat_id: str) -> bool: """Status updates (Stories) and Channel/Newsletter broadcasts — never reply (answering a Story spams the status feed; Channel posts aren't addressable).""" - if not chat_id: - return False - cid = chat_id.strip().lower() - return cid == "status@broadcast" or cid.endswith("@broadcast") or cid.endswith("@newsletter") + cid = (chat_id or "").strip().lower() + return cid == "status@broadcast" or cid.endswith(("@broadcast", "@newsletter")) # ------------------------------------------------------------------ gating def _open_dm_opted_in(self) -> bool: - if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}: + if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in _OPTIN_TRUTHY: return True - return (_get_wsecret("WHATSAPP_ALLOW_ALL_USERS", default="") or "").lower() in {"true", "1", "yes"} + return (_get_wsecret("WHATSAPP_ALLOW_ALL_USERS", default="") or "").lower() in _OPTIN_TRUTHY @staticmethod def _matches_whatsapp_allowlist(candidate: str, allow_from) -> bool: @@ -169,29 +173,22 @@ class WhatsAppBehaviorMixin: return False if candidate in allow_from: return True - from gateway.whatsapp_identity import ( - expand_whatsapp_aliases, - normalize_whatsapp_identifier, - ) + from gateway.whatsapp_identity import expand_whatsapp_aliases, normalize_whatsapp_identifier candidate_aliases = expand_whatsapp_aliases(candidate) if not candidate_aliases: return False - for entry in allow_from: - if entry == "*": - return True - if normalize_whatsapp_identifier(entry) in candidate_aliases: - return True - if expand_whatsapp_aliases(entry) & candidate_aliases: - return True - return False + return any( + entry == "*" + or normalize_whatsapp_identifier(entry) in candidate_aliases + or expand_whatsapp_aliases(entry) & candidate_aliases + for entry in allow_from + ) def _is_dm_allowed(self, sender_id: str) -> bool: """Strict DM authorization — pairing does not imply access.""" if self._dm_policy == "allowlist": return self._matches_whatsapp_allowlist(sender_id, self._live_dm_allow_from()) - if self._dm_policy == "open": - return self._open_dm_opted_in() - return False + return self._dm_policy == "open" and self._open_dm_opted_in() def _is_dm_intake_allowed(self, sender_id: str) -> bool: """Whether a DM may reach the gateway intake (pairing handshake path).""" @@ -202,9 +199,7 @@ class WhatsAppBehaviorMixin: return self._matches_whatsapp_allowlist(principal, self._live_dm_allow_from()) if self._dm_policy == "pairing": return True - if self._dm_policy == "open": - return self._open_dm_opted_in() - return False + return self._dm_policy == "open" and self._open_dm_opted_in() def _is_group_allowed(self, chat_id: str) -> bool: """Check whether a group chat should be processed.""" @@ -220,9 +215,9 @@ class WhatsAppBehaviorMixin: try: patterns = json.loads(raw) except Exception: - patterns = [part.strip() for part in raw.splitlines() if part.strip()] - if not patterns: - patterns = [part.strip() for part in raw.split(",") if part.strip()] + # Plain text: one pattern per line, else comma-separated. + patterns = [p.strip() for p in raw.splitlines() if p.strip()] + patterns = patterns or [p.strip() for p in raw.split(",") if p.strip()] if patterns is None: return [] if isinstance(patterns, str): @@ -246,36 +241,24 @@ class WhatsAppBehaviorMixin: return compiled def _bot_ids_from_message(self, data: Dict[str, Any]) -> set[str]: - bot_ids = set() - for candidate in data.get("botIds") or []: - normalized = self._normalize_whatsapp_id(candidate) - if normalized: - bot_ids.add(normalized) - return bot_ids + return {nid for c in (data.get("botIds") or []) if (nid := self._normalize_whatsapp_id(c))} def _message_is_reply_to_bot(self, data: Dict[str, Any]) -> bool: quoted_participant = self._normalize_whatsapp_id(data.get("quotedParticipant")) - if not quoted_participant: - return False - return quoted_participant in self._bot_ids_from_message(data) + return bool(quoted_participant) and quoted_participant in self._bot_ids_from_message(data) def _message_mentions_bot(self, data: Dict[str, Any]) -> bool: bot_ids = self._bot_ids_from_message(data) if not bot_ids: return False - mentioned_ids = { - nid - for candidate in (data.get("mentionedIds") or []) - if (nid := self._normalize_whatsapp_id(candidate)) - } - if mentioned_ids & bot_ids: + mentioned = {nid for c in (data.get("mentionedIds") or []) if (nid := self._normalize_whatsapp_id(c))} + if mentioned & bot_ids: return True lower_body = str(data.get("body") or "").lower() - for bot_id in bot_ids: - bare_id = bot_id.split("@", 1)[0].lower() - if bare_id and (f"@{bare_id}" in lower_body or bare_id in lower_body): - return True - return False + return any( + bare and (f"@{bare}" in lower_body or bare in lower_body) + for bare in (bot_id.split("@", 1)[0].lower() for bot_id in bot_ids) + ) def _message_matches_mention_patterns(self, data: Dict[str, Any]) -> bool: if not self._mention_patterns: @@ -300,23 +283,18 @@ class WhatsAppBehaviorMixin: return False if not data.get("isGroup", False): # DMs that pass the policy gate are always processed - sender_id = str(data.get("senderId") or data.get("from") or "") - return self._is_dm_intake_allowed(sender_id) + return self._is_dm_intake_allowed(str(data.get("senderId") or data.get("from") or "")) if not self._is_group_allowed(chat_id): return False # Group messages: check mention / free-response settings - if chat_id in self._whatsapp_free_response_chats(): + if chat_id in self._whatsapp_free_response_chats() or not self._whatsapp_require_mention(): return True - if not self._whatsapp_require_mention(): - return True - body = str(data.get("body") or "").strip() - if body.startswith("/"): - return True - if self._message_is_reply_to_bot(data): - return True - if self._message_mentions_bot(data): - return True - return self._message_matches_mention_patterns(data) + return ( + str(data.get("body") or "").strip().startswith("/") + or self._message_is_reply_to_bot(data) + or self._message_mentions_bot(data) + or self._message_matches_mention_patterns(data) + ) # ------------------------------------------------------------------ formatting def format_message(self, content: str) -> str: @@ -324,46 +302,19 @@ class WhatsAppBehaviorMixin: inline code are protected via placeholder substitution.""" if not content: return content - content = self._sanitize_outbound_text(content) - _FENCE_PH = "\x00FENCE" - fences: list[str] = [] - - def _save_fence(m: re.Match) -> str: - fences.append(m.group(0)) - return f"{_FENCE_PH}{len(fences) - 1}\x00" - - result = re.sub(r"```[\s\S]*?```", _save_fence, content) - _CODE_PH = "\x00CODE" - codes: list[str] = [] - - def _save_code(m: re.Match) -> str: - codes.append(m.group(0)) - return f"{_CODE_PH}{len(codes) - 1}\x00" - - result = re.sub(r"`[^`\n]+`", _save_code, result) - + result, fences = _stash(r"```[\s\S]*?```", self._sanitize_outbound_text(content), "FENCE") + result, codes = _stash(r"`[^`\n]+`", result, "CODE") # Italic *text* → _text_ BEFORE bold so **bold** doesn't become italic; # lookarounds skip list bullets and bold delimiters. result = re.sub(r"(? str: - inner = m.group(1).strip() - while len(inner) > 1 and inner.startswith("*") and inner.endswith("*"): - inner = inner[1:-1].strip() - return f"*{inner}*" - result = re.sub(r"^#{1,6}\s+(.+)$", _header_to_bold, result, flags=re.MULTILINE) - # [text](url) → text (url) - result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result) - for i, fence in enumerate(fences): - result = result.replace(f"{_FENCE_PH}{i}\x00", fence) - for i, code in enumerate(codes): - result = result.replace(f"{_CODE_PH}{i}\x00", code) + result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result) # [text](url) → text (url) + for tag, saved in (("FENCE", fences), ("CODE", codes)): + for i, original in enumerate(saved): + result = result.replace(f"\x00{tag}{i}\x00", original) return result @@ -376,14 +327,11 @@ def resolve_whatsapp_bridge_dir() -> Path: install_bridge = _Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge" hermes_home_bridge = get_hermes_home() / "scripts" / "whatsapp-bridge" try: - test_file = install_bridge / ".write_test" - test_file.touch() - test_file.unlink() - install_writable = True - except (OSError, PermissionError): - install_writable = False - if install_writable: + (install_bridge / ".write_test").touch() + (install_bridge / ".write_test").unlink() return install_bridge + except OSError: + pass if hermes_home_bridge.exists(): return hermes_home_bridge try: From b4ef0e9b43d43527feab881a680c5c659b84b287 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:14:56 -0700 Subject: [PATCH 04/10] refactor(gateway/yuanbao-sticker): fold score ladder into max(), hug lookups; table byte-identical --- gateway/platforms/yuanbao_sticker.py | 95 +++++++++------------------- 1 file changed, 29 insertions(+), 66 deletions(-) diff --git a/gateway/platforms/yuanbao_sticker.py b/gateway/platforms/yuanbao_sticker.py index 3119c38d79..5998ba53e5 100644 --- a/gateway/platforms/yuanbao_sticker.py +++ b/gateway/platforms/yuanbao_sticker.py @@ -1,11 +1,9 @@ """ Yuanbao sticker (TIMFaceElem) support. Ported from yuanbao-openclaw-plugin/src/sticker/. -TIMFaceElem wire format: - {"msg_type": "TIMFaceElem", - "msg_content": {"index": 0, # always 0 per Yuanbao convention - "data": ""}} # serialised sticker metadata so the receiver - # can look up the asset in the emoji pack +TIMFaceElem wire format: {"msg_type": "TIMFaceElem", "msg_content": {"index": 0, "data": ""}} +index is always 0 per Yuanbao convention; data is serialised sticker metadata so the receiver +can look up the asset in the emoji pack. """ from __future__ import annotations @@ -90,11 +88,7 @@ STICKER_MAP: dict[str, dict] = { def get_sticker_by_name(name: str) -> Optional[dict]: - """按名称查找贴纸,支持模糊匹配。 - - 匹配优先级:完全相等 → name 与查询词互为子串 → description 包含查询词 → - search_stickers 模糊评分最高的一条。找不到返回 None。 - """ + """完全相等 → name 与查询词互为子串 → description 包含查询词 → search_stickers 最高分;找不到返回 None。""" if not name: return None query = name.strip() @@ -104,7 +98,7 @@ def get_sticker_by_name(name: str) -> Optional[dict]: if query in key or key in query: return sticker for sticker in STICKER_MAP.values(): - if query in sticker.get("description", ""): + if query in sticker["description"]: return sticker matches = search_stickers(query, limit=1) return matches[0] if matches else None @@ -112,27 +106,18 @@ def get_sticker_by_name(name: str) -> Optional[dict]: def get_random_sticker(category: str = None) -> dict: """随机贴纸;指定 category 时优先在 description/name 含该词的贴纸中选取。""" - if category: - candidates = [ - s for s in STICKER_MAP.values() - if category in s.get("description", "") or category in s.get("name", "") - ] - if candidates: - return random.choice(candidates) - return random.choice(list(STICKER_MAP.values())) + candidates = [s for s in STICKER_MAP.values() if category in s["description"] or category in s["name"]] if category else [] + return random.choice(candidates or list(STICKER_MAP.values())) def get_sticker_by_id(sticker_id: str) -> Optional[dict]: - """按 sticker_id 精确查找贴纸。""" if not sticker_id: return None sid = str(sticker_id).strip() - return next((s for s in STICKER_MAP.values() if s.get("sticker_id") == sid), None) + return next((s for s in STICKER_MAP.values() if s["sticker_id"] == sid), None) -# --------------------------------------------------------------------------- # 模糊搜索(对齐 chatbot-web yuanbao-openclaw-plugin/sticker-cache.ts.searchStickers) -# --------------------------------------------------------------------------- _PUNCT_RE = re.compile(r"[\s\u3000\-_·.,,。!!??\"“”'‘’、/\\]+") @@ -153,10 +138,9 @@ def _multiset_char_hit_ratio(needle: str, haystack: str) -> float: bag[ch] = bag.get(ch, 0) + 1 hits = 0 for ch in needle: - n = bag.get(ch, 0) - if n > 0: + if bag.get(ch, 0) > 0: hits += 1 - bag[ch] = n - 1 + bag[ch] -= 1 return hits / len(needle) @@ -189,21 +173,16 @@ def _score_field(haystack: str, query: str) -> float: return 0.0 hay_c = _compact_text(haystack) q_c = _compact_text(query) - best = 0.0 - if hay == q: - best = max(best, 100.0) - if q in hay: - best = max(best, 92 + min(6, len(q))) - if len(q) >= 2 and hay.startswith(q): - best = max(best, 88.0) - if q_c and q_c in hay_c: - best = max(best, 86.0) - best = max(best, _multiset_char_hit_ratio(q_c, hay_c) * 62) - best = max(best, _bigram_jaccard(q_c, hay_c) * 58) - best = max(best, _longest_subsequence_ratio(q_c, hay_c) * 52) - if len(q) == 1 and q in hay: - best = max(best, 68.0) - return best + return max( + 100.0 if hay == q else 0.0, + 92 + min(6, len(q)) if q in hay else 0.0, + 88.0 if len(q) >= 2 and hay.startswith(q) else 0.0, + 86.0 if q_c and q_c in hay_c else 0.0, + _multiset_char_hit_ratio(q_c, hay_c) * 62, + _bigram_jaccard(q_c, hay_c) * 58, + _longest_subsequence_ratio(q_c, hay_c) * 52, + 68.0 if len(q) == 1 and q in hay else 0.0, + ) def search_stickers(query: str, limit: int = 10) -> list[dict]: @@ -216,35 +195,23 @@ def search_stickers(query: str, limit: int = 10) -> list[dict]: q_norm = _normalize_text(query) if not query or not q_norm: return list(STICKER_MAP.values())[:safe_limit] - scored: list[tuple[float, dict]] = [] for sticker in STICKER_MAP.values(): - name_s = _score_field(sticker.get("name", ""), query) - desc_s = _score_field(sticker.get("description", ""), query) * 0.88 - sid_norm = _normalize_text(str(sticker.get("sticker_id", "")).strip()) + sid_norm = _normalize_text(sticker["sticker_id"].strip()) id_s = 100.0 if sid_norm == q_norm else 84.0 if sid_norm and q_norm in sid_norm else 0.0 - scored.append((max(name_s, desc_s, id_s), sticker)) - + scored.append((max(_score_field(sticker["name"], query), _score_field(sticker["description"], query) * 0.88, id_s), sticker)) scored.sort(key=lambda x: x[0], reverse=True) top = scored[0][0] if scored else 0 if top <= 0: return [s for _, s in scored[:safe_limit]] - if top >= 22: - floor = 18.0 - elif top >= 12: - floor = max(10.0, top * 0.5) - else: - floor = max(6.0, top * 0.35) + floor = 18.0 if top >= 22 else max(10.0, top * 0.5) if top >= 12 else max(6.0, top * 0.35) filtered = [pair for pair in scored if pair[0] >= floor] return [s for _, s in (filtered or scored)[:safe_limit]] def build_face_msg_body(face_index: int, face_type: int = 1, data: Optional[str] = None) -> list: - """构造 TIMFaceElem 消息体。 - - Yuanbao 约定 index 固定 0(服务端通过 data 字段识别表情);face_index > 0 视为旧版 QQ 表情 ID。 - face_type 为兼容旧接口保留,不影响 wire format。data 为 None 时仅传 index。 - """ + """TIMFaceElem 消息体。Yuanbao 约定 index 固定 0(服务端通过 data 字段识别表情);face_index > 0 视为旧版 QQ + 表情 ID。face_type 为兼容旧接口保留,不影响 wire format。data 为 None 时仅传 index。""" msg_content: dict = {"index": face_index} if data is not None: msg_content["data"] = data @@ -255,14 +222,10 @@ def build_sticker_msg_body(sticker: dict) -> list: """从 STICKER_MAP 的 sticker dict 构造 TIMFaceElem 消息体(data 字段与原始 JS 插件一致)。""" data_payload = json.dumps( { - "sticker_id": sticker["sticker_id"], - "package_id": sticker["package_id"], - "width": sticker.get("width", 128), - "height": sticker.get("height", 128), - "formats": sticker.get("formats", "png"), - "name": sticker["name"], + "sticker_id": sticker["sticker_id"], "package_id": sticker["package_id"], + "width": sticker.get("width", 128), "height": sticker.get("height", 128), + "formats": sticker.get("formats", "png"), "name": sticker["name"], }, - ensure_ascii=False, - separators=(",", ":"), + ensure_ascii=False, separators=(",", ":"), ) return build_face_msg_body(face_index=0, data=data_payload) From 24b573ce437aa7eb38b0e24e94cbb3c1e1a242ae Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:17:16 -0700 Subject: [PATCH 05/10] refactor(gateway/yuanbao-proto): one _encode_parts table encoder for head/auth/elements/sends, spec-driven inbound push --- gateway/platforms/yuanbao_proto.py | 224 ++++++++++------------------- 1 file changed, 72 insertions(+), 152 deletions(-) diff --git a/gateway/platforms/yuanbao_proto.py b/gateway/platforms/yuanbao_proto.py index b823986fd4..4e90a5eeaf 100644 --- a/gateway/platforms/yuanbao_proto.py +++ b/gateway/platforms/yuanbao_proto.py @@ -1,11 +1,8 @@ -""" -yuanbao_proto.py - Yuanbao WebSocket 协议编解码(手写 protobuf wire-format,不依赖 google.protobuf) +"""yuanbao_proto.py - Yuanbao WebSocket 协议编解码(手写 protobuf wire-format,不依赖 google.protobuf) -每个 WebSocket frame = 一条 ConnMsg protobuf(标准 protobuf,非 conn.proto 注释里的 magic+len 二进制格式, -那只用于 quic/tcp): +每个 WebSocket frame = 一条 ConnMsg protobuf(标准 protobuf;conn.proto 注释里的 magic+len 二进制格式只用于 quic/tcp): ConnMsg { Head head=1 (cmd_type, cmd, seq_no, msg_id, module, ...); bytes data=2 } - data = 业务 payload(InboundMessagePush / SendC2CMessageReq / SendGroupMessageReq / ..., - 包 trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.*) + data = 业务 payload(InboundMessagePush / SendC2CMessageReq / ...,包 trpc.yuanbao.yuanbao_conn.yuanbao_openclaw_proxy.*) """ from __future__ import annotations @@ -28,14 +25,9 @@ MODULE = {"ConnAccess": "conn_access"} _BIZ_PKG = "yuanbao_openclaw_proxy" BIZ_SERVICES = { n: f"{_BIZ_PKG}.{n}" - for n in ( - "InboundMessagePush", - "SendC2CMessageReq", "SendC2CMessageRsp", - "SendGroupMessageReq", "SendGroupMessageRsp", - "QueryGroupInfoReq", "QueryGroupInfoRsp", - "GetGroupMemberListReq", "GetGroupMemberListRsp", - "SendPrivateHeartbeatReq", "SendPrivateHeartbeatRsp", - "SendGroupHeartbeatReq", "SendGroupHeartbeatRsp", + for n in ("InboundMessagePush",) + tuple( + f"{m}{k}" for m in ("SendC2CMessage", "SendGroupMessage", "QueryGroupInfo", "GetGroupMemberList", + "SendPrivateHeartbeat", "SendGroupHeartbeat") for k in ("Req", "Rsp") ) } @@ -57,9 +49,7 @@ def next_seq_no() -> int: return val -# ============================================================ -# Protobuf wire-format 基础工具 -# ============================================================ +# ---- Protobuf wire-format 基础工具 WT_VARINT = 0 WT_64BIT = 1 @@ -187,6 +177,24 @@ def _parse_repeated(fdict: dict, fn: int) -> list[dict]: return [_parse_dict(b) for b in _get_repeated_bytes(fdict, fn)] +# 字段表编码:parts = [(field_number, kind, value)];kind: +# "S" string 总是编码 "s" string 非空才编码 "v" varint 非零才编码 "n" varint 非 None 才编码 "m" 嵌套 bytes 非空才编码 +# "b" repeated MsgBodyElement "t" LogInfoExt{1 trace_id} 非空才编码 +_PART_ENCODERS = { + "S": _s, "s": _s, "v": _v, "n": _v, "m": _m, + "b": lambda fn, body: b"".join(_m(fn, _encode_msg_body_element(el)) for el in body), + "t": lambda fn, trace_id: _m(fn, _s(1, trace_id)), +} + + +def _encode_parts(parts: list) -> bytes: + buf = b"" + for fn, kind, val in parts: + if kind == "S" or (val is not None if kind == "n" else val): + buf += _PART_ENCODERS[kind](fn, val) + return buf + + # 字段表驱动编解码:spec = [(field_number, key, kind)],kind: # "s" string(编码时 str(v)) "r" string(原值) "i" varint(编码时 int(v)) # 编码跳过 falsy 值;解码只保留 truthy 值。spec 顺序即 wire 顺序和 dict 插入顺序。 @@ -211,33 +219,19 @@ def _decode_spec(fdict: dict, spec: list) -> dict: return out -# ============================================================ -# ConnMsg 层编解码 +# ---- ConnMsg 层编解码 # message Head { uint32 cmd_type=1; string cmd=2; uint32 seq_no=3; string msg_id=4; # string module=5; bool need_ack=6; ... int32 status=10; } # message ConnMsg { Head head=1; bytes data=2; } -# ============================================================ def _encode_head( cmd_type: int, cmd: str, seq_no: int, msg_id: str, module: str, need_ack: bool = False, status: int = 0, ) -> bytes: - buf = b"" - if cmd_type != 0: - buf += _v(1, cmd_type) - if cmd: - buf += _s(2, cmd) - if seq_no != 0: - buf += _v(3, seq_no) - if msg_id: - buf += _s(4, msg_id) - if module: - buf += _s(5, module) - if need_ack: - buf += _v(6, 1) - if status != 0: - buf += _v(10, status & 0xFFFFFFFFFFFFFFFF) - return buf + return _encode_parts([ + (1, "v", cmd_type), (2, "s", cmd), (3, "v", seq_no), (4, "s", msg_id), (5, "s", module), + (6, "v", 1 if need_ack else 0), (10, "v", status & 0xFFFFFFFFFFFFFFFF), + ]) def _decode_head(data: bytes) -> dict: @@ -273,10 +267,8 @@ def _conn_request(cmd_type: int, cmd: str, msg_id: str, module: str, data: bytes return encode_conn_msg_full(cmd_type, cmd, next_seq_no(), msg_id, module, data) -# ============================================================ -# BizMsg 层:业务 body 包装成 ConnMsg(head.cmd = method, head.module = service) +# ---- BizMsg 层:业务 body 包装成 ConnMsg(head.cmd = method, head.module = service) # 与 conn-codec.ts buildBusinessConnMsg(cmd, module, bizData, msgId) 行为一致。 -# ============================================================ def encode_biz_msg(service: str, method: str, req_id: str, body: bytes) -> bytes: @@ -299,9 +291,7 @@ def _biz_request(method: str, prefix: str, body: bytes, msg_id: str = "") -> byt return encode_biz_msg(_BIZ_PKG, method, msg_id or f"{prefix}_{next_seq_no()}", body) -# ============================================================ -# 业务 protobuf 消息编解码(biz payload) -# ============================================================ +# ---- 业务 protobuf 消息编解码(biz payload) # MsgContent:1 text, 2 uuid, 3 image_format, 4 data, 5 desc, 6 ext, 7 sound, # 8 image_info_array (repeated), 9 index, 10 url, 11 file_size, 12 file_name, @@ -342,12 +332,8 @@ def _decode_msg_content(data: bytes) -> dict: # MsgBodyElement:1 msg_type (string, e.g. "TIMTextElem"), 2 msg_content (MsgContent) def _encode_msg_body_element(element: dict) -> bytes: - buf = b"" - if element.get("msg_type", ""): - buf += _s(1, element["msg_type"]) - if element.get("msg_content", {}): - buf += _m(2, _encode_msg_content(element["msg_content"])) - return buf + content = element.get("msg_content", {}) + return _encode_parts([(1, "s", element.get("msg_type", "")), (2, "m", _encode_msg_content(content) if content else b"")]) def _decode_msg_body_element(data: bytes) -> dict: @@ -356,52 +342,35 @@ def _decode_msg_body_element(data: bytes) -> dict: return {"msg_type": _get_string(fdict, 1), "msg_content": _decode_msg_content(content_bytes) if content_bytes else {}} -# ============================================================ -# 入站消息解析 -# ============================================================ +# ---- 入站消息解析 + + +# InboundMessagePush 字段表 [(field_number, key, getter)];getter 为 _get_string / _get_varint 或自定义 (fdict, fn) -> value +_INBOUND_PUSH_SPEC = [ + (1, "callback_command", _get_string), (2, "from_account", _get_string), (3, "to_account", _get_string), + (4, "sender_nickname", _get_string), (5, "group_id", _get_string), (6, "group_code", _get_string), + (7, "group_name", _get_string), (8, "msg_seq", _get_varint), (9, "msg_random", _get_varint), + (10, "msg_time", _get_varint), (11, "msg_key", _get_string), (12, "msg_id", _get_string), + (13, "msg_body", lambda fd, fn: [_decode_msg_body_element(b) for b in _get_repeated_bytes(fd, fn)]), + (14, "cloud_custom_data", _get_string), (15, "event_time", _get_varint), (16, "bot_owner_id", _get_string), + (17, "recall_msg_seq_list", lambda fd, fn: [ # repeated ImMsgSeq{1 msg_seq, 2 msg_id} + {"msg_seq": _get_varint(d, 1), "msg_id": _get_string(d, 2)} for d in _parse_repeated(fd, fn)] or None), + (18, "claw_msg_type", _get_varint), (19, "private_from_group_code", _get_string), + (20, "trace_id", lambda fd, fn: _get_string(_parse_dict(_get_bytes(fd, fn)), 1) if _get_bytes(fd, fn) else ""), # LogInfoExt +] def decode_inbound_push(data: bytes) -> Optional[dict]: - """解析 InboundMessagePush biz payload(字段号见 dict)。 - - 空值已过滤,msg_body / msg_seq 始终保留;recall_msg_seq_list 为 [{msg_seq, msg_id}] 或 None; - 解析失败返回 None。 - """ + """解析 InboundMessagePush biz payload;空值已过滤(msg_body / msg_seq 始终保留),解析失败返回 None。""" try: fdict = _parse_dict(data) - log_ext_bytes = _get_bytes(fdict, 20) - result: dict = { - "callback_command": _get_string(fdict, 1), - "from_account": _get_string(fdict, 2), - "to_account": _get_string(fdict, 3), - "sender_nickname": _get_string(fdict, 4), - "group_id": _get_string(fdict, 5), - "group_code": _get_string(fdict, 6), - "group_name": _get_string(fdict, 7), - "msg_seq": _get_varint(fdict, 8), - "msg_random": _get_varint(fdict, 9), - "msg_time": _get_varint(fdict, 10), - "msg_key": _get_string(fdict, 11), - "msg_id": _get_string(fdict, 12), - "msg_body": [_decode_msg_body_element(b) for b in _get_repeated_bytes(fdict, 13)], - "cloud_custom_data": _get_string(fdict, 14), - "event_time": _get_varint(fdict, 15), - "bot_owner_id": _get_string(fdict, 16), - "recall_msg_seq_list": [ - {"msg_seq": _get_varint(d, 1), "msg_id": _get_string(d, 2)} for d in _parse_repeated(fdict, 17) - ] or None, - "claw_msg_type": _get_varint(fdict, 18), - "private_from_group_code": _get_string(fdict, 19), - "trace_id": _get_string(_parse_dict(log_ext_bytes), 1) if log_ext_bytes else "", # 20 log_ext{1 trace_id} - } + result = {key: get(fdict, fn) for fn, key, get in _INBOUND_PUSH_SPEC} return {k: v for k, v in result.items() if v or k in {"msg_body", "msg_seq"}} except Exception: return None -# ============================================================ -# WeChat forwarded chat-history parsing (ForwardMsgData) -# ============================================================ +# ---- WeChat forwarded chat-history parsing (ForwardMsgData) # ext_map["wexin_forward_msg__"] = base64(ForwardMsgData) — protobuf, NOT JSON. # Verified against live captures: # ForwardMsgData { uint32 sub_type=1 (1 = WeChat chat-history forward); uint32 begin_time=2; @@ -446,81 +415,39 @@ def decode_forward_msg_data(data: bytes) -> Optional[dict]: return None -# ============================================================ -# Outbound message encoding -# ============================================================ -# 发送请求字段表 [(field_number, kind, value)];kind: -# "S" string 总是编码 "s" string 非空才编码 "v" varint 非零才编码 "n" varint 非 None 才编码 -# "b" repeated MsgBodyElement "t" LogInfoExt{1 trace_id} 非空才编码 -_SEND_ENCODERS = { - "S": _s, "s": _s, "v": _v, "n": _v, - "b": lambda fn, body: b"".join(_m(fn, _encode_msg_body_element(el)) for el in body), - "t": lambda fn, trace_id: _m(fn, _s(1, trace_id)), -} - - -def _send_request(method: str, prefix: str, msg_id: str, parts: list) -> bytes: - buf = b"" - for fn, kind, val in parts: - if kind == "S" or (val is not None if kind == "n" else val): - buf += _SEND_ENCODERS[kind](fn, val) - return _biz_request(method, prefix, buf, msg_id) - - +# ---- Outbound message encoding def encode_send_c2c_message( - to_account: str, - msg_body: list, - from_account: str, - msg_id: str = "", - msg_random: int = 0, - msg_seq: Optional[int] = None, - group_code: str = "", - trace_id: str = "", + to_account: str, msg_body: list, from_account: str, msg_id: str = "", msg_random: int = 0, + msg_seq: Optional[int] = None, group_code: str = "", trace_id: str = "", ) -> bytes: """SendC2CMessageReq → 完整 ConnMsg bytes(可直接发送)。 msg_body items are {"msg_type": str, "msg_content": dict}; msg_id doubles as req_id when set; group_code is filled for the "private chat originating from a group" case. """ - return _send_request("send_c2c_message", "c2c", msg_id, [ + return _biz_request("send_c2c_message", "c2c", _encode_parts([ (1, "s", msg_id), (2, "S", to_account), (3, "s", from_account), (4, "v", msg_random), (5, "b", msg_body), (6, "s", group_code), (7, "n", msg_seq), (8, "t", trace_id), - ]) + ]), msg_id) def encode_send_group_message( - group_code: str, - msg_body: list, - from_account: str, - msg_id: str = "", - to_account: str = "", - random: str = "", - msg_seq: Optional[int] = None, - ref_msg_id: str = "", - trace_id: str = "", + group_code: str, msg_body: list, from_account: str, msg_id: str = "", to_account: str = "", random: str = "", + msg_seq: Optional[int] = None, ref_msg_id: str = "", trace_id: str = "", ) -> bytes: """SendGroupMessageReq → 完整 ConnMsg bytes。to_account usually empty; ref_msg_id = quoted message.""" - return _send_request("send_group_message", "grp", msg_id, [ + return _biz_request("send_group_message", "grp", _encode_parts([ (1, "s", msg_id), (2, "S", group_code), (3, "s", from_account), (4, "s", to_account), (5, "s", random), (6, "b", msg_body), (7, "s", ref_msg_id), (8, "n", msg_seq), (9, "t", trace_id), - ]) + ]), msg_id) -# ============================================================ -# AuthBind / Ping / PushAck -# ============================================================ +# ---- AuthBind / Ping / PushAck def encode_auth_bind( - biz_id: str, - uid: str, - source: str, - token: str, - msg_id: str, - app_version: str = "", - operation_system: str = "", - bot_version: str = "", - route_env: str = "", + biz_id: str, uid: str, source: str, token: str, msg_id: str, app_version: str = "", operation_system: str = "", + bot_version: str = "", route_env: str = "", ) -> bytes: """auth-bind 请求 ConnMsg bytes。 @@ -528,17 +455,12 @@ def encode_auth_bind( 3 device_info (DeviceInfo{1 app_version, 2 app_operation_system, 10 instance_id, 24 bot_version}), 5 env_name """ - dev_buf = b"" - if app_version: - dev_buf += _s(1, app_version) - if operation_system: - dev_buf += _s(2, operation_system) - dev_buf += _s(10, str(HERMES_INSTANCE_ID)) - if bot_version: - dev_buf += _s(24, bot_version) - req_buf = _s(1, biz_id) + _m(2, _s(1, uid) + _s(2, source) + _s(3, token)) + _m(3, dev_buf) - if route_env: - req_buf += _s(5, route_env) + dev_buf = _encode_parts([ + (1, "s", app_version), (2, "s", operation_system), (10, "S", str(HERMES_INSTANCE_ID)), (24, "s", bot_version), + ]) + req_buf = _encode_parts([ + (1, "S", biz_id), (2, "m", _s(1, uid) + _s(2, source) + _s(3, token)), (3, "m", dev_buf), (5, "s", route_env), + ]) return _conn_request(CMD_TYPE["Request"], CMD["AuthBind"], msg_id, MODULE["ConnAccess"], req_buf) @@ -554,9 +476,7 @@ def encode_push_ack(original_head: dict) -> bytes: ) -# ============================================================ -# Heartbeat / 群信息 / 群成员列表 -# ============================================================ +# ---- Heartbeat / 群信息 / 群成员列表 def encode_send_private_heartbeat(from_account: str, to_account: str, heartbeat: int = WS_HEARTBEAT_RUNNING) -> bytes: From 127f1752d0dc412ae70c461b27d8a0162b8ea9e2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:20:41 -0700 Subject: [PATCH 06/10] refactor(gateway/yuanbao-proto): hug signatures/dicts in proto+media, Counter-based sticker scoring --- gateway/platforms/yuanbao_media.py | 50 +++++++--------------------- gateway/platforms/yuanbao_proto.py | 41 ++++++++++------------- gateway/platforms/yuanbao_sticker.py | 23 +++---------- 3 files changed, 34 insertions(+), 80 deletions(-) diff --git a/gateway/platforms/yuanbao_media.py b/gateway/platforms/yuanbao_media.py index e422041c45..9371e0868a 100644 --- a/gateway/platforms/yuanbao_media.py +++ b/gateway/platforms/yuanbao_media.py @@ -189,14 +189,8 @@ def _sorted_kv(d: dict[str, str]) -> list[tuple[str, str]]: def _cos_sign( - method: str, - path: str, - params: dict[str, str], - headers: dict[str, str], - secret_id: str, - secret_key: str, - start_time: Optional[int] = None, - expire_seconds: int = 3600, + method: str, path: str, params: dict[str, str], headers: dict[str, str], secret_id: str, secret_key: str, + start_time: Optional[int] = None, expire_seconds: int = 3600, ) -> str: """COS Authorization 头(q-sign-algorithm=sha1;https://cloud.tencent.com/document/product/436/7778)。 @@ -208,12 +202,8 @@ def _cos_sign( sign_key = _hmac_sha1_hex(secret_key, q_sign_time) # SignKey = HMAC-SHA1(SecretKey, q-sign-time) sorted_params = _sorted_kv(params) sorted_headers = _sorted_kv(headers) - http_string = "\n".join([ - method.lower(), path, - "&".join(f"{k}={v}" for k, v in sorted_params), - "&".join(f"{k}={v}" for k, v in sorted_headers), - "", - ]) + kv = lambda pairs: "&".join(f"{k}={v}" for k, v in pairs) # noqa: E731 + http_string = "\n".join([method.lower(), path, kv(sorted_params), kv(sorted_headers), ""]) string_to_sign = "\n".join(["sha1", q_sign_time, hashlib.sha1(http_string.encode("utf-8")).hexdigest(), ""]) return ( f"q-sign-algorithm=sha1&q-ak={secret_id}&q-sign-time={q_sign_time}&q-key-time={q_sign_time}" @@ -226,13 +216,8 @@ def _cos_sign( # ============ 主要公开 API ============ async def get_cos_credentials( - app_key: str, - api_domain: str, - token: str, - filename: str = "file", - file_id: Optional[str] = None, - bot_id: str = "", - route_env: str = "", + app_key: str, api_domain: str, token: str, filename: str = "file", file_id: Optional[str] = None, + bot_id: str = "", route_env: str = "", ) -> dict: """调用 genUploadInfo 获取 COS 临时密钥及上传配置。 @@ -260,22 +245,16 @@ async def get_cos_credentials( async def upload_to_cos( - file_bytes: bytes, - filename: str, - content_type: str, - credentials: dict, - bucket: str, - region: str, + file_bytes: bytes, filename: str, content_type: str, credentials: dict, bucket: str, region: str, ) -> dict: """用临时凭证(get_cos_credentials() 返回的 dict)HMAC-SHA1 签名,httpx PUT 上传到 COS(走全球加速域名)。 Returns {url, uuid (内容 MD5), size, width?, height? (仅图片)} Raises httpx.HTTPStatusError(COS 非 2xx)/ RuntimeError(credentials 字段缺失) """ - secret_id: str = credentials.get("encryptTmpSecretId", "") - secret_key: str = credentials.get("encryptTmpSecretKey", "") - session_token: str = credentials.get("encryptToken", "") - cos_key: str = credentials.get("location", "") + secret_id, secret_key, session_token, cos_key = ( + credentials.get(k, "") for k in ("encryptTmpSecretId", "encryptTmpSecretKey", "encryptToken", "location") + ) start_time: Optional[int] = credentials.get("startTime") expired_time: Optional[int] = credentials.get("expiredTime") if not secret_id or not secret_key or not cos_key: @@ -311,13 +290,8 @@ async def upload_to_cos( # ============ TIM 媒体消息构建(https://cloud.tencent.com/document/product/269/2720) ============ def build_image_msg_body( - url: str, - uuid: Optional[str] = None, - filename: Optional[str] = None, - size: int = 0, - width: int = 0, - height: int = 0, - mime_type: str = "", + url: str, uuid: Optional[str] = None, filename: Optional[str] = None, size: int = 0, width: int = 0, + height: int = 0, mime_type: str = "", ) -> list[dict]: """TIMImageElem 消息体(可直接放入 msg_body)。uuid 缺省依次退到 filename / URL basename / "image"。""" return [{ diff --git a/gateway/platforms/yuanbao_proto.py b/gateway/platforms/yuanbao_proto.py index 4e90a5eeaf..7937f29662 100644 --- a/gateway/platforms/yuanbao_proto.py +++ b/gateway/platforms/yuanbao_proto.py @@ -235,11 +235,10 @@ def _encode_head( def _decode_head(data: bytes) -> dict: - fdict = _parse_dict(data) + fd = _parse_dict(data) return { - "cmd_type": _get_varint(fdict, 1), "cmd": _get_string(fdict, 2), "seq_no": _get_varint(fdict, 3), - "msg_id": _get_string(fdict, 4), "module": _get_string(fdict, 5), - "need_ack": bool(_get_varint(fdict, 6)), "status": _get_varint(fdict, 10), + "cmd_type": _get_varint(fd, 1), "cmd": _get_string(fd, 2), "seq_no": _get_varint(fd, 3), "msg_id": _get_string(fd, 4), + "module": _get_string(fd, 5), "need_ack": bool(_get_varint(fd, 6)), "status": _get_varint(fd, 10), } @@ -322,11 +321,8 @@ def _decode_msg_content(data: bytes) -> dict: fdict = _parse_dict(data) content = _decode_spec(fdict, _MSG_CONTENT_SPEC) imgs = [img for img in (_decode_spec(d, _IMAGE_INFO_SPEC) for d in _parse_repeated(fdict, 8)) if img] - if imgs: - content["image_info_array"] = imgs ext_map = {_get_string(e, 1): _get_string(e, 2) for e in _parse_repeated(fdict, 999) if _get_string(e, 1)} - if ext_map: - content["ext_map"] = ext_map + content.update({k: v for k, v in (("image_info_array", imgs), ("ext_map", ext_map)) if v}) return content @@ -395,21 +391,19 @@ def _decode_forward_msg_content(data: bytes) -> dict: return content -def _decode_forward_msg(fdict: dict) -> dict: - return { - "sender": _get_string(fdict, 1), "time": _get_varint(fdict, 2), "plainText": _get_string(fdict, 3), - "msgContent": [_decode_forward_msg_content(b) for b in _get_repeated_bytes(fdict, 4)], - } +def _decode_forward_msg(fd: dict) -> dict: + return {"sender": _get_string(fd, 1), "time": _get_varint(fd, 2), "plainText": _get_string(fd, 3), + "msgContent": [_decode_forward_msg_content(b) for b in _get_repeated_bytes(fd, 4)]} def decode_forward_msg_data(data: bytes) -> Optional[dict]: """Parse ForwardMsgData bytes (base64-decoded ext_map value) into the {sub_type, nick_name, msg, ...} structure consumed by ForwardedRecordsParseMiddleware.build_forward_text; None on parse failure.""" try: - fdict = _parse_dict(data) + fd = _parse_dict(data) return { - "sub_type": _get_varint(fdict, 1), "begin_time": _get_varint(fdict, 2), "end_time": _get_varint(fdict, 3), - "nick_name": _get_string(fdict, 4), "msg": [_decode_forward_msg(d) for d in _parse_repeated(fdict, 5)], + "sub_type": _get_varint(fd, 1), "begin_time": _get_varint(fd, 2), "end_time": _get_varint(fd, 3), + "nick_name": _get_string(fd, 4), "msg": [_decode_forward_msg(d) for d in _parse_repeated(fd, 5)], } except Exception: return None @@ -532,15 +526,14 @@ def decode_get_group_member_list_rsp(data: bytes) -> Optional[dict]: member dict 过滤空值但保留 role;解析失败返回 None。""" try: fdict = _parse_dict(data) - members = [] - for m in _parse_repeated(fdict, 3): - member = { - "user_id": _get_string(m, 1), "nickname": _get_string(m, 2), "role": _get_varint(m, 3), - "join_time": _get_varint(m, 4), "name_card": _get_string(m, 5), - } - members.append({k: v for k, v in member.items() if v or k == "role"}) + members = [ + {"user_id": _get_string(m, 1), "nickname": _get_string(m, 2), "role": _get_varint(m, 3), + "join_time": _get_varint(m, 4), "name_card": _get_string(m, 5)} + for m in _parse_repeated(fdict, 3) + ] return { - "code": _get_varint(fdict, 1), "message": _get_string(fdict, 2), "members": members, + "code": _get_varint(fdict, 1), "message": _get_string(fdict, 2), + "members": [{k: v for k, v in mem.items() if v or k == "role"} for mem in members], "next_offset": _get_varint(fdict, 4), "is_complete": bool(_get_varint(fdict, 5)), } except Exception: diff --git a/gateway/platforms/yuanbao_sticker.py b/gateway/platforms/yuanbao_sticker.py index 5998ba53e5..8ff19b6a6d 100644 --- a/gateway/platforms/yuanbao_sticker.py +++ b/gateway/platforms/yuanbao_sticker.py @@ -12,6 +12,7 @@ import json import random import re import unicodedata +from collections import Counter from typing import Optional # Sticker catalogue – ported from builtin-stickers.json. Every builtin sticker is in @@ -131,27 +132,14 @@ def _compact_text(raw: str) -> str: def _multiset_char_hit_ratio(needle: str, haystack: str) -> float: - if not needle: - return 0.0 - bag: dict[str, int] = {} - for ch in haystack: - bag[ch] = bag.get(ch, 0) + 1 - hits = 0 - for ch in needle: - if bag.get(ch, 0) > 0: - hits += 1 - bag[ch] -= 1 - return hits / len(needle) + return sum((Counter(needle) & Counter(haystack)).values()) / len(needle) if needle else 0.0 def _bigram_jaccard(a: str, b: str) -> float: if len(a) < 2 or len(b) < 2: return 0.0 - A = {a[i:i + 2] for i in range(len(a) - 1)} - B = {b[i:i + 2] for i in range(len(b) - 1)} - inter = len(A & B) - union = len(A) + len(B) - inter - return inter / union if union else 0.0 + A, B = ({x[i:i + 2] for i in range(len(x) - 1)} for x in (a, b)) + return len(A & B) / len(A | B) def _longest_subsequence_ratio(needle: str, haystack: str) -> float: @@ -161,8 +149,7 @@ def _longest_subsequence_ratio(needle: str, haystack: str) -> float: for ch in haystack: if j >= len(needle): break - if ch == needle[j]: - j += 1 + j += ch == needle[j] return j / len(needle) From 13d1915b7ea1bcff69b037134682bcdba450adba Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:22:02 -0700 Subject: [PATCH 07/10] refactor(gateway/whatsapp): unify Cloud outbound envelope/interactive/tap-state helpers; body extractor table; ingest helper --- gateway/platforms/whatsapp_cloud.py | 798 +++++++++++----------------- 1 file changed, 321 insertions(+), 477 deletions(-) diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index b056dfe4ca..a5662ad931 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -1,23 +1,12 @@ """ -WhatsApp Cloud API adapter — official Meta WhatsApp Business Platform. +WhatsApp Cloud API adapter — official Meta WhatsApp Business Platform (Graph API + +aiohttp webhook). Complements the Baileys bridge plugin; both share gating / mention / +formatting behavior via ``WhatsAppBehaviorMixin``. -Complement to the Baileys bridge plugin (``plugins/platforms/whatsapp``): official -Cloud API, Business account + public webhook URL required, token auth. Both share -gating / mention / formatting behavior via ``WhatsAppBehaviorMixin``. - -Required env vars to enable the adapter: -- WHATSAPP_CLOUD_PHONE_NUMBER_ID (the Graph URL path component) -- WHATSAPP_CLOUD_ACCESS_TOKEN (System User permanent token) - -Optional: -- WHATSAPP_CLOUD_APP_ID -- WHATSAPP_CLOUD_APP_SECRET (HMAC key for X-Hub-Signature-256) -- WHATSAPP_CLOUD_WABA_ID (analytics / future use) -- WHATSAPP_CLOUD_VERIFY_TOKEN (hub.verify_token shared secret) -- WHATSAPP_CLOUD_WEBHOOK_HOST (default: unset → dual-stack, all interfaces IPv4+IPv6) -- WHATSAPP_CLOUD_WEBHOOK_PORT (default 8090) -- WHATSAPP_CLOUD_WEBHOOK_PATH (default /whatsapp/webhook) -- WHATSAPP_CLOUD_API_VERSION (default v20.0) +Required env: WHATSAPP_CLOUD_PHONE_NUMBER_ID, WHATSAPP_CLOUD_ACCESS_TOKEN. Optional: +WHATSAPP_CLOUD_APP_ID, _APP_SECRET (HMAC key for X-Hub-Signature-256), _WABA_ID, +_VERIFY_TOKEN (hub.verify_token), _WEBHOOK_HOST (unset → dual-stack all interfaces), +_WEBHOOK_PORT (8090), _WEBHOOK_PATH (/whatsapp/webhook), _API_VERSION (v20.0). """ from __future__ import annotations @@ -54,7 +43,7 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult -from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin, _get_wsecret +from gateway.platforms.whatsapp_common import _OPTIN_TRUTHY, WhatsAppBehaviorMixin, _get_wsecret from gateway.platforms.media_cache import ext_for_mime from gateway import rich_sent_store from hermes_constants import get_hermes_dir @@ -131,6 +120,22 @@ _MESSAGE_TYPE_BY_KIND = { "location": MessageType.TEXT, "contacts": MessageType.TEXT, } +_HTTP_PREFIXES = ("http://", "https://") + + +def _interactive_inner(raw_message: Dict[str, Any]) -> Dict[str, Any]: + """``button_reply`` (type=button) or ``list_reply`` (type=list); both carry id+title.""" + inter = raw_message.get("interactive") or {} + return inter.get("button_reply") or inter.get("list_reply") or {} + + +# Inbound message type → body text extractor; media kinds use the caption. +_BODY_BY_KIND = { + "text": lambda m: (m.get("text") or {}).get("body"), + "button": lambda m: (m.get("button") or {}).get("text"), + "interactive": lambda m: _interactive_inner(m).get("title"), + **{kind: (lambda m, k=kind: (m.get(k) or {}).get("caption")) for kind in _INBOUND_MEDIA_KINDS}, +} async def _read_limited_request_body(request: Any, max_bytes: int) -> bytes: @@ -154,6 +159,14 @@ def _ext_for_mime(mime: str) -> Optional[str]: ) +def _cloud_allow_all_opted_in() -> bool: + return str(_get_wsecret("WHATSAPP_CLOUD_ALLOW_ALL_USERS", default="") or "").strip().lower() in _OPTIN_TRUTHY + + +def _reply_to_from(metadata: Optional[Dict[str, Any]]) -> Optional[str]: + return (metadata or {}).get("reply_to_message_id") + + # Under the hermes dir so it survives restarts/reloads — same as the Baileys bridge. _INBOUND_MEDIA_CACHE = Path(get_hermes_dir("platforms/whatsapp_cloud/media", "whatsapp_cloud/media")) @@ -178,35 +191,30 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self._app_secret: str = str(extra.get("app_secret", "")).strip() self._waba_id: str = str(extra.get("waba_id", "")).strip() self._verify_token: str = str(extra.get("verify_token", "")).strip() - # Falsy host (None/"") collapses to the dual-stack default. - _raw_webhook_host = extra.get("webhook_host", DEFAULT_WEBHOOK_HOST) or DEFAULT_WEBHOOK_HOST - self._webhook_host: Optional[str] = str(_raw_webhook_host) if _raw_webhook_host else None + raw_host = extra.get("webhook_host", DEFAULT_WEBHOOK_HOST) or DEFAULT_WEBHOOK_HOST + self._webhook_host: Optional[str] = str(raw_host) if raw_host else None self._webhook_port: int = int(extra.get("webhook_port", DEFAULT_WEBHOOK_PORT)) self._webhook_path: str = self._normalize_path(extra.get("webhook_path", DEFAULT_WEBHOOK_PATH)) self._health_path: str = self._normalize_path(extra.get("health_path", "/health")) self._api_version: str = str(extra.get("api_version", DEFAULT_API_VERSION)) - # Behavior-mixin contract attributes. WHATSAPP_CLOUD_* env vars take - # precedence so both adapters can run in parallel with independent - # policies; shared WHATSAPP_* names remain the fallback. + # Behavior-mixin contract attributes. WHATSAPP_CLOUD_* env vars take precedence so + # both adapters can run in parallel with independent policies; shared WHATSAPP_* + # names remain the fallback. Allowlist: config, then legacy ALLOW_FROM, then ALLOWED_USERS. self._reply_prefix: Optional[str] = extra.get("reply_prefix") - # Config first, then legacy ALLOW_FROM, then the documented ALLOWED_USERS. allow_raw = self._select_dm_allowlist( extra, ("WHATSAPP_CLOUD_ALLOW_FROM", "WHATSAPP_CLOUD_ALLOWED_USERS"), _get_wsecret ) self._allow_from: set[str] = self._normalize_allow_ids(self._coerce_allow_list(allow_raw)) # DM policy default: "open" if the operator opted into allow-all, else # "allowlist" when one is configured (so it is enforced), else "open". - _allow_all_optin = str( - _get_wsecret("WHATSAPP_CLOUD_ALLOW_ALL_USERS", default="") or "" - ).strip().lower() in {"true", "1", "yes"} - _default_dm_policy = "open" if _allow_all_optin else ("allowlist" if self._allow_from else "open") + default_dm_policy = "allowlist" if self._allow_from and not _cloud_allow_all_opted_in() else "open" self._dm_policy: str = str( extra.get("dm_policy") or _get_wsecret("WHATSAPP_CLOUD_DM_POLICY") or _get_wsecret("WHATSAPP_DM_POLICY") - or _default_dm_policy + or default_dm_policy ).strip().lower() self._group_policy: str = str( extra.get("group_policy") @@ -214,26 +222,22 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): or _get_wsecret("WHATSAPP_GROUP_POLICY", default="open") or "open" ).strip().lower() - self._group_allow_from: set[str] = self._normalize_allow_ids( - self._coerce_allow_list( - extra.get("group_allow_from") - or extra.get("groupAllowFrom") - or _get_wsecret("WHATSAPP_CLOUD_GROUP_ALLOW_FROM") - ) - ) + self._group_allow_from: set[str] = self._normalize_allow_ids(self._coerce_allow_list( + extra.get("group_allow_from") + or extra.get("groupAllowFrom") + or _get_wsecret("WHATSAPP_CLOUD_GROUP_ALLOW_FROM") + )) self._mention_patterns = self._compile_mention_patterns() - # Webhook dedup state (in-memory, FIFO-evicted). + # Webhook dedup state (in-memory, FIFO-evicted) and counters. self._seen_wamids: "OrderedDict[str, bool]" = OrderedDict() self._duplicate_count: int = 0 self._accepted_count: int = 0 self._rejected_signature_count: int = 0 self._warned_no_ffmpeg: bool = False - # Latest inbound wamid per chat: Meta's typing/read-receipt API needs a # message_id to attach to, and the base send_typing contract has none. self._last_inbound_wamid_by_chat: "OrderedDict[str, str]" = OrderedDict() - # Interactive-button state: short id (in the button payload) → session_key # for the gateway resolver. Popped on tap; FIFO-capped via _bounded_put so # ignored prompts don't accumulate (an evicted tap degrades to text fallback). @@ -257,9 +261,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): def _auth_headers(self, *, json_body: bool = True) -> Dict[str, str]: headers = {"Authorization": f"Bearer {self._access_token}"} - if json_body: - headers["Content-Type"] = "application/json" - return headers + return {**headers, "Content-Type": "application/json"} if json_body else headers @staticmethod def _bounded_put(cache: "OrderedDict[str, str]", key: str, value: str) -> None: @@ -270,65 +272,49 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): def _effective_reply_prefix(self) -> str: """Cloud API has no self-chat concept (a Baileys-only setting) — no default prefix.""" - if self._reply_prefix is not None: - return self._reply_prefix.replace("\\n", "\n") - return "" + return self._reply_prefix.replace("\\n", "\n") if self._reply_prefix is not None else "" @staticmethod def _normalize_allow_ids(ids: set[str]) -> set[str]: """Normalize allowlist entries to bare wa_id (digits): strip ``@...`` JID suffixes and non-digits.""" - normalized: set[str] = set() - for entry in ids: - bare = entry.split("@", 1)[0] - digits = re.sub(r"\D", "", bare) - normalized.add(digits or entry) - return normalized + return {re.sub(r"\D", "", entry.split("@", 1)[0]) or entry for entry in ids} def _is_dm_allowed(self, sender_id: str) -> bool: """Allowlist check against the normalized bare wa_id.""" if self._dm_policy == "allowlist": bare = re.sub(r"\D", "", str(sender_id).split("@", 1)[0]) - allow_from = self._normalize_allow_ids(self._live_dm_allow_from()) - return (bare or sender_id) in allow_from + return (bare or sender_id) in self._normalize_allow_ids(self._live_dm_allow_from()) return super()._is_dm_allowed(sender_id) def _open_dm_opted_in(self) -> bool: """Also honor the documented WHATSAPP_CLOUD_ALLOW_ALL_USERS opt-in.""" - if str(_get_wsecret("WHATSAPP_CLOUD_ALLOW_ALL_USERS", default="") or "").strip().lower() in {"true", "1", "yes"}: - return True - return super()._open_dm_opted_in() + return _cloud_allow_all_opted_in() or super()._open_dm_opted_in() def _is_interactive_sender_authorized(self, sender_id: str) -> bool: """Interactive taps bypass ``_should_process_message``; re-check the strict DM gate so a stale prompt can't be answered after the sender leaves the allowlist.""" principal = str(sender_id or "").strip() - if not principal: - return False - return self._is_dm_allowed(principal) + return bool(principal) and self._is_dm_allowed(principal) # ------------------------------------------------------------------ lifecycle async def connect(self, *, is_reconnect: bool = False) -> bool: if not check_whatsapp_cloud_requirements(): self._set_fatal_error( "whatsapp_cloud_deps_missing", - "aiohttp and httpx are required for whatsapp_cloud — " - "reinstall hermes-agent.", + "aiohttp and httpx are required for whatsapp_cloud — reinstall hermes-agent.", retryable=False, ) return False if not self._phone_number_id or not self._access_token: self._set_fatal_error( "whatsapp_cloud_unconfigured", - "WHATSAPP_CLOUD_PHONE_NUMBER_ID and WHATSAPP_CLOUD_ACCESS_TOKEN " - "are required.", + "WHATSAPP_CLOUD_PHONE_NUMBER_ID and WHATSAPP_CLOUD_ACCESS_TOKEN are required.", retryable=False, ) return False - # Tighter keepalive so idle CLOSE_WAIT drains promptly. from gateway.platforms._http_client_limits import platform_httpx_limits self._http_client = httpx.AsyncClient(timeout=30.0, limits=platform_httpx_limits()) - # client_max_size backstops the bounded reader in _handle_webhook. app = web.Application(client_max_size=WEBHOOK_MAX_BODY_BYTES) app.router.add_get(self._health_path, self._handle_health) @@ -336,13 +322,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): app.router.add_post(self._webhook_path, self._handle_webhook) self._runner = web.AppRunner(app) await self._runner.setup() - site = web.TCPSite(self._runner, self._webhook_host, self._webhook_port) - await site.start() + await web.TCPSite(self._runner, self._webhook_host, self._webhook_port).start() self._mark_connected() logger.info( "[whatsapp_cloud] Listening on %s:%d%s (Graph %s, phone_id=%s)", - self._webhook_host, self._webhook_port, self._webhook_path, - self._api_version, self._phone_number_id, + self._webhook_host, self._webhook_port, self._webhook_path, self._api_version, self._phone_number_id, ) if not self._verify_token: logger.warning( @@ -374,6 +358,14 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self._mark_disconnected() # ------------------------------------------------------------------ outbound + @staticmethod + def _format_graph_error(body: Dict[str, Any], status_code: int) -> str: + # Graph shape: {"error": {"message", "type", "code", "fbtrace_id"}} + err = (body or {}).get("error") or {} + message = err.get("message") or body.get("raw") or "unknown error" + code = err.get("code") + return f"graph error {code} (HTTP {status_code}): {message}" if code is not None else f"HTTP {status_code}: {message}" + @staticmethod def _response_error(resp: Any) -> str: """Map a non-200 Graph response to an actionable error string.""" @@ -388,9 +380,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): ) -> tuple[list, Optional[str]]: """POST one /messages payload. Returns ``(response messages[], None)`` or ``([], error)``.""" try: - resp = await self._http_client.post( - self._graph_url("messages"), headers=self._auth_headers(), json=payload - ) + resp = await self._http_client.post(self._graph_url("messages"), headers=self._auth_headers(), json=payload) except Exception as exc: logger.exception(fail_log) return [], str(exc) or type(exc).__name__ @@ -403,6 +393,29 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): except Exception: return [], None + async def _post_message_result(self, payload: Dict[str, Any], **log_kwargs) -> SendResult: + """``_post_messages`` → SendResult with the first returned message id; guards disconnected state.""" + if self._http_client is None: + return SendResult(success=False, error="Not connected") + ids, err = await self._post_messages(payload, **log_kwargs) + if err is not None: + return SendResult(success=False, error=err) + return SendResult(success=True, message_id=ids[0].get("id") if ids else None) + + @staticmethod + def _outbound_payload(chat_id: str, kind: str, block: Any, reply_to: Optional[str]) -> Dict[str, Any]: + """Common ``/messages`` envelope; ``context`` quotes ``reply_to`` when given.""" + payload: Dict[str, Any] = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": chat_id, + "type": kind, + kind: block, + } + if reply_to: + payload["context"] = {"message_id": reply_to} + return payload + async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, @@ -413,19 +426,12 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if not content or not content.strip(): return SendResult(success=True, message_id=None) formatted = self.format_message(content) - chunks = self.truncate_message(formatted, self._outgoing_chunk_limit()) last_message_id: Optional[str] = None - for idx, chunk in enumerate(chunks): - payload: Dict[str, Any] = { - "messaging_product": "whatsapp", - "recipient_type": "individual", - "to": chat_id, - "type": "text", - "text": {"body": chunk, "preview_url": True}, - } - if reply_to and idx == 0: - # Quote the user's message on the first chunk only. - payload["context"] = {"message_id": reply_to} + for idx, chunk in enumerate(self.truncate_message(formatted, self._outgoing_chunk_limit())): + # Quote the user's message on the first chunk only. + payload = self._outbound_payload( + chat_id, "text", {"body": chunk, "preview_url": True}, reply_to if idx == 0 else None + ) ids, err = await self._post_messages( payload, fail_log="[whatsapp_cloud] send failed", @@ -435,7 +441,6 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return SendResult(success=False, error=err) if ids: last_message_id = ids[0].get("id") - # Index (chat_id, wamid) → text: Meta's inbound ``context`` carries only the # quoted message's id, so this is how replies to our messages resolve text. if last_message_id: @@ -462,28 +467,21 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): "typing_indicator": {"type": "text"}, } try: - resp = await self._http_client.post( - self._graph_url("messages"), headers=self._auth_headers(), json=payload - ) + resp = await self._http_client.post(self._graph_url("messages"), headers=self._auth_headers(), json=payload) except Exception: return + if resp.status_code == 200: + return + try: + code = ((resp.json() or {}).get("error") or {}).get("code") + except Exception: + code = None # Code 131009 = "Parameter value is not valid" (typically wamid > 30 days # old) — common after a long-quiet conversation, so log at info. - if resp.status_code != 200: - try: - code = ((resp.json() or {}).get("error") or {}).get("code") - except Exception: - code = None - if code == 131009: - logger.info( - "[whatsapp_cloud] typing/read indicator rejected: " - "wamid %s likely older than 30 days", wamid, - ) - else: - logger.debug( - "[whatsapp_cloud] typing/read indicator returned %d (%s)", - resp.status_code, code, - ) + if code == 131009: + logger.info("[whatsapp_cloud] typing/read indicator rejected: wamid %s likely older than 30 days", wamid) + else: + logger.debug("[whatsapp_cloud] typing/read indicator returned %d (%s)", resp.status_code, code) # ------------------------------------------------------------------ interactive messages # @@ -496,46 +494,42 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self, chat_id: str, interactive_body: Dict[str, Any], reply_to: Optional[str] = None, ) -> SendResult: """POST an ``interactive`` message; caller supplies ``type``/``body``/``action``.""" - if self._http_client is None: - return SendResult(success=False, error="Not connected") - payload: Dict[str, Any] = { - "messaging_product": "whatsapp", - "recipient_type": "individual", - "to": chat_id, - "type": "interactive", - "interactive": interactive_body, - } - if reply_to: - payload["context"] = {"message_id": reply_to} - ids, err = await self._post_messages( - payload, + return await self._post_message_result( + self._outbound_payload(chat_id, "interactive", interactive_body, reply_to), fail_log="[whatsapp_cloud] interactive send failed", reject_log="[whatsapp_cloud] interactive rejected (status=%d): %s", ) - if err is not None: - return SendResult(success=False, error=err) - return SendResult(success=True, message_id=ids[0].get("id") if ids else None) + + async def _send_interactive( + self, chat_id: str, interactive: Dict[str, Any], metadata: Optional[Dict[str, Any]], + state: "OrderedDict[str, str]", state_id: str, session_key: str, + ) -> SendResult: + """Post an interactive prompt and, on success, remember ``state_id → session_key`` for the tap.""" + result = await self._post_interactive(chat_id, interactive, reply_to=_reply_to_from(metadata)) + if result.success: + self._bounded_put(state, state_id, session_key) + return result @staticmethod def _truncate_button_label(text: str, limit: int = 20) -> str: """Button titles cap at 20 chars, list-row titles at 24; the ellipsis counts.""" text = str(text or "").strip() - if len(text) <= limit: - return text - return text[: max(1, limit - 1)] + "…" + return text if len(text) <= limit else text[: max(1, limit - 1)] + "…" @staticmethod def _truncate_body(text: str, limit: int = 1024) -> str: """``interactive.body.text`` caps at 1024 chars.""" text = str(text or "") - if len(text) <= limit: - return text - return text[: limit - 3] + "..." + return text if len(text) <= limit else text[: limit - 3] + "..." @staticmethod def _reply_buttons(*buttons: tuple[str, str]) -> list[Dict[str, Any]]: return [{"type": "reply", "reply": {"id": bid, "title": title}} for bid, title in buttons] + @classmethod + def _button_interactive(cls, body_text: str, *buttons: tuple[str, str]) -> Dict[str, Any]: + return {"type": "button", "body": {"text": body_text}, "action": {"buttons": cls._reply_buttons(*buttons)}} + async def send_clarify( self, chat_id: str, question: str, choices: Optional[list], clarify_id: str, session_key: str, metadata: Optional[Dict[str, Any]] = None, @@ -548,24 +542,18 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if self._http_client is None: return SendResult(success=False, error="Not connected") question = (question or "").strip() - reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None if not choices: - return await self.send(chat_id, f"❓ {question}", reply_to=reply_to) - + return await self.send(chat_id, f"❓ {question}", reply_to=_reply_to_from(metadata)) # Full choice text goes in the body so long options aren't lost to the # 20-char label cap; labels are just the option number. choices_list = [str(c).strip() for c in choices[:10] if str(c).strip()] option_lines = "\n".join(f"{i + 1}. {c}" for i, c in enumerate(choices_list)) body_text = self._truncate_body(f"❓ {question}\n\n{option_lines}") if len(choices_list) <= 3: - interactive: Dict[str, Any] = { - "type": "button", - "body": {"text": body_text}, - "action": {"buttons": self._reply_buttons(*( - (f"cl:{clarify_id}:{idx}", self._truncate_button_label(str(idx + 1))) - for idx in range(len(choices_list)) - ))}, - } + interactive = self._button_interactive(body_text, *( + (f"cl:{clarify_id}:{idx}", self._truncate_button_label(str(idx + 1))) + for idx in range(len(choices_list)) + )) else: # List rows: id + title (≤24) + description (≤72) with the choice text. rows = [ @@ -576,20 +564,13 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): } for idx, choice_text in enumerate(choices_list) ] - rows.append({ - "id": f"cl:{clarify_id}:other", - "title": "✏️ Other", - "description": "Type your own answer", - }) + rows.append({"id": f"cl:{clarify_id}:other", "title": "✏️ Other", "description": "Type your own answer"}) interactive = { "type": "list", "body": {"text": body_text}, "action": {"button": "Choose", "sections": [{"title": "Options", "rows": rows}]}, } - result = await self._post_interactive(chat_id, interactive, reply_to=reply_to) - if result.success: - self._bounded_put(self._clarify_state, clarify_id, session_key) - return result + return await self._send_interactive(chat_id, interactive, metadata, self._clarify_state, clarify_id, session_key) async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", @@ -597,8 +578,6 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): allow_session: bool = True, smart_denied: bool = False, ) -> SendResult: """Approve / Deny buttons; a tap resolves via ``tools.approval.resolve_gateway_approval``.""" - if self._http_client is None: - return SendResult(success=False, error="Not connected") del allow_permanent, allow_session # This adapter already offers one-shot Approve / Deny only. # Body caps at 1024; reserve room for the framing prose. cmd = command or "" @@ -610,52 +589,27 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): + ("\n\nSmart DENY: owner override applies to this one operation only." if smart_denied else "") ) approval_id = uuid.uuid4().hex[:12] - reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None - interactive = { - "type": "button", - "body": {"text": body_text}, - "action": {"buttons": self._reply_buttons( - (f"appr:{approval_id}:approve", "✅ Approve"), - (f"appr:{approval_id}:deny", "❌ Deny"), - )}, - } - result = await self._post_interactive(chat_id, interactive, reply_to=reply_to) - if result.success: - self._bounded_put(self._exec_approval_state, approval_id, session_key) - return result + interactive = self._button_interactive( + body_text, (f"appr:{approval_id}:approve", "✅ Approve"), (f"appr:{approval_id}:deny", "❌ Deny"), + ) + return await self._send_interactive( + chat_id, interactive, metadata, self._exec_approval_state, approval_id, session_key + ) async def send_slash_confirm( self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Approve Once / Always / Cancel buttons; ``confirm_id`` is caller-supplied.""" - if self._http_client is None: - return SendResult(success=False, error="Not connected") - body_text = self._truncate_body(f"*{title}*\n\n{message}") - reply_to = (metadata or {}).get("reply_to_message_id") if metadata else None - interactive = { - "type": "button", - "body": {"text": body_text}, - "action": {"buttons": self._reply_buttons( - (f"sc:once:{confirm_id}", "✅ Approve Once"), - (f"sc:always:{confirm_id}", "🔒 Always"), - (f"sc:cancel:{confirm_id}", "❌ Cancel"), - )}, - } - result = await self._post_interactive(chat_id, interactive, reply_to=reply_to) - if result.success: - self._bounded_put(self._slash_confirm_state, confirm_id, session_key) - return result - - @staticmethod - def _format_graph_error(body: Dict[str, Any], status_code: int) -> str: - # Graph shape: {"error": {"message", "type", "code", "fbtrace_id"}} - err = (body or {}).get("error") or {} - message = err.get("message") or body.get("raw") or "unknown error" - code = err.get("code") - if code is not None: - return f"graph error {code} (HTTP {status_code}): {message}" - return f"HTTP {status_code}: {message}" + interactive = self._button_interactive( + self._truncate_body(f"*{title}*\n\n{message}"), + (f"sc:once:{confirm_id}", "✅ Approve Once"), + (f"sc:always:{confirm_id}", "🔒 Always"), + (f"sc:cancel:{confirm_id}", "❌ Cancel"), + ) + return await self._send_interactive( + chat_id, interactive, metadata, self._slash_confirm_state, confirm_id, session_key + ) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: # No chat-info endpoint; profile name arrives via webhook contacts[]. @@ -673,14 +627,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): size = os.path.getsize(file_path) cap = _MEDIA_SIZE_LIMITS.get(media_kind, _MEDIA_SIZE_LIMITS["document"]) if size > cap: - return None, ( - f"File {os.path.basename(file_path)} is {size} bytes; " - f"Cloud API {media_kind} cap is {cap} bytes" - ) - if not mime_type: - mime_type, _ = mimetypes.guess_type(file_path) - if not mime_type: - mime_type = _DEFAULT_MIME.get(media_kind, "application/octet-stream") + return None, f"File {os.path.basename(file_path)} is {size} bytes; Cloud API {media_kind} cap is {cap} bytes" + mime_type = mime_type or mimetypes.guess_type(file_path)[0] or _DEFAULT_MIME.get(media_kind, "application/octet-stream") try: with open(file_path, "rb") as fh: files = { @@ -700,9 +648,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): media_id = resp.json().get("id") except Exception: media_id = None - if not media_id: - return None, "Upload response missing 'id'" - return media_id, None + return (media_id, None) if media_id else (None, "Upload response missing 'id'") async def _send_media( self, chat_id: str, media_kind: str, *, media_id: Optional[str] = None, @@ -717,33 +663,17 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return SendResult(success=False, error="Not connected") if bool(media_id) == bool(media_link): return SendResult(success=False, error="Exactly one of media_id or media_link must be set") - media_block: Dict[str, Any] = {} - if media_id: - media_block["id"] = media_id - else: - media_block["link"] = media_link + media_block: Dict[str, Any] = {"id": media_id} if media_id else {"link": media_link} if caption and media_kind in {"image", "video", "document"}: media_block["caption"] = caption if filename and media_kind == "document": media_block["filename"] = filename - payload: Dict[str, Any] = { - "messaging_product": "whatsapp", - "recipient_type": "individual", - "to": chat_id, - "type": media_kind, - media_kind: media_block, - } - if reply_to: - payload["context"] = {"message_id": reply_to} - ids, err = await self._post_messages( - payload, + return await self._post_message_result( + self._outbound_payload(chat_id, media_kind, media_block, reply_to), fail_log="[whatsapp_cloud] media send failed", reject_log="[whatsapp_cloud] media send rejected (status=%d, kind=%s): %s", reject_args=(media_kind,), ) - if err is not None: - return SendResult(success=False, error=err) - return SendResult(success=True, message_id=ids[0].get("id") if ids else None) async def _send_media_from_path_or_link( self, chat_id: str, source: str, media_kind: str, *, caption: Optional[str] = None, @@ -751,71 +681,24 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): mime_type: Optional[str] = None, ) -> SendResult: """HTTPS URL → ``link`` send (one fewer round trip); local path → upload + ``id`` send.""" - if source.startswith(("http://", "https://")): - return await self._send_media( - chat_id, media_kind, media_link=source, caption=caption, filename=filename, reply_to=reply_to - ) - media_id, err = await self._upload_media(source, media_kind, mime_type) - if err: - return SendResult(success=False, error=err) - return await self._send_media( - chat_id, media_kind, media_id=media_id, caption=caption, filename=filename, reply_to=reply_to - ) + ref: Dict[str, Optional[str]] = {"media_link": source} + if not source.startswith(_HTTP_PREFIXES): + media_id, err = await self._upload_media(source, media_kind, mime_type) + if err: + return SendResult(success=False, error=err) + ref = {"media_id": media_id} + return await self._send_media(chat_id, media_kind, caption=caption, filename=filename, reply_to=reply_to, **ref) # ``**kwargs`` absorbs base-class args (e.g. ``metadata``) the Cloud API has no use for. - async def send_image( - self, chat_id: str, image_url: str, caption: Optional[str] = None, - reply_to: Optional[str] = None, **kwargs, - ) -> SendResult: + async def send_image(self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: return await self._send_media_from_path_or_link(chat_id, image_url, "image", caption=caption, reply_to=reply_to) - async def send_image_file( - self, chat_id: str, image_path: str, caption: Optional[str] = None, - reply_to: Optional[str] = None, **kwargs, - ) -> SendResult: + async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: return await self._send_media_from_path_or_link(chat_id, image_path, "image", caption=caption, reply_to=reply_to) - async def send_video( - self, chat_id: str, video_path: str, caption: Optional[str] = None, - reply_to: Optional[str] = None, **kwargs, - ) -> SendResult: + async def send_video(self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: return await self._send_media_from_path_or_link(chat_id, video_path, "video", caption=caption, reply_to=reply_to) - async def send_voice( - self, chat_id: str, audio_path: str, caption: Optional[str] = None, - reply_to: Optional[str] = None, **kwargs, - ) -> SendResult: - """Voice message: ``audio/ogg; codecs=opus`` renders as a voice bubble, so a - local MP3 (Hermes TTS output) is converted via ffmpeg first; other audio is sent as-is.""" - source = audio_path - mime_type: Optional[str] = None - is_local_mp3 = ( - not audio_path.startswith(("http://", "https://")) - and audio_path.lower().endswith(".mp3") - and os.path.exists(audio_path) - ) - if is_local_mp3: - opus_path = await self._convert_to_opus(audio_path) - if opus_path: - try: - result = await self._send_media_from_path_or_link( - chat_id, opus_path, "audio", - caption=caption, reply_to=reply_to, - mime_type="audio/ogg; codecs=opus", - ) - finally: - # The .ogg is a transient artifact next to the source MP3. - try: - os.unlink(opus_path) - except OSError: - pass - return result - # No ffmpeg (warn-once logged in _convert_to_opus) → MP3 attachment. - mime_type = "audio/mpeg" - return await self._send_media_from_path_or_link( - chat_id, source, "audio", caption=caption, reply_to=reply_to, mime_type=mime_type, - ) - 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, **kwargs, @@ -825,7 +708,38 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): caption=caption, filename=file_name or os.path.basename(file_path), reply_to=reply_to, ) - # ------------------------------------------------------------------ opus conversion + async def send_voice( + self, chat_id: str, audio_path: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, **kwargs, + ) -> SendResult: + """Voice message: ``audio/ogg; codecs=opus`` renders as a voice bubble, so a + local MP3 (Hermes TTS output) is converted via ffmpeg first; other audio is sent as-is.""" + mime_type: Optional[str] = None + is_local_mp3 = ( + not audio_path.startswith(_HTTP_PREFIXES) + and audio_path.lower().endswith(".mp3") + and os.path.exists(audio_path) + ) + if is_local_mp3: + opus_path = await self._convert_to_opus(audio_path) + if opus_path: + try: + return await self._send_media_from_path_or_link( + chat_id, opus_path, "audio", caption=caption, reply_to=reply_to, + mime_type="audio/ogg; codecs=opus", + ) + finally: + # The .ogg is a transient artifact next to the source MP3. + try: + os.unlink(opus_path) + except OSError: + pass + # No ffmpeg (warn-once logged in _convert_to_opus) → MP3 attachment. + mime_type = "audio/mpeg" + return await self._send_media_from_path_or_link( + chat_id, audio_path, "audio", caption=caption, reply_to=reply_to, mime_type=mime_type, + ) + async def _convert_to_opus(self, mp3_path: str) -> Optional[str]: """MP3 → ``audio/ogg; codecs=opus``; None if ffmpeg is missing or fails. @@ -833,44 +747,48 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): WhatsApp's native voice-note bitrate. """ if not _FFMPEG_PATH: - self._warn_once_no_ffmpeg() + if not self._warned_no_ffmpeg: + self._warned_no_ffmpeg = True + logger.warning( + "[whatsapp_cloud] ffmpeg not found on PATH — voice messages will " + "be delivered as MP3 audio attachments instead of native voice " + "notes (green waveform bubble). Install ffmpeg to enable: " + "Windows `winget install Gyan.FFmpeg`, macOS `brew install ffmpeg`, " + "Linux package manager." + ) return None out_path = mp3_path.rsplit(".", 1)[0] + ".ogg" try: proc = await asyncio.create_subprocess_exec( _FFMPEG_PATH, "-y", "-i", mp3_path, - "-c:a", "libopus", "-b:a", "32k", "-vbr", "on", - "-application", "voip", out_path, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.PIPE, + "-c:a", "libopus", "-b:a", "32k", "-vbr", "on", "-application", "voip", out_path, + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) _, stderr = await proc.communicate() - if proc.returncode != 0 or not Path(out_path).exists(): - logger.error( - "[whatsapp_cloud] ffmpeg opus conversion failed " - "(returncode=%s): %s", - proc.returncode, - (stderr or b"").decode("utf-8", errors="replace")[:500], - ) - return None - return out_path except Exception: logger.exception("[whatsapp_cloud] ffmpeg subprocess raised") return None - - def _warn_once_no_ffmpeg(self) -> None: - if self._warned_no_ffmpeg: - return - self._warned_no_ffmpeg = True - logger.warning( - "[whatsapp_cloud] ffmpeg not found on PATH — voice messages will " - "be delivered as MP3 audio attachments instead of native voice " - "notes (green waveform bubble). Install ffmpeg to enable: " - "Windows `winget install Gyan.FFmpeg`, macOS `brew install ffmpeg`, " - "Linux package manager." - ) + if proc.returncode != 0 or not Path(out_path).exists(): + logger.error( + "[whatsapp_cloud] ffmpeg opus conversion failed (returncode=%s): %s", + proc.returncode, (stderr or b"").decode("utf-8", errors="replace")[:500], + ) + return None + return out_path # ------------------------------------------------------------------ inbound media + async def _graph_get(self, url: str, headers: Dict[str, str], what: str, media_id: str) -> Any: + """GET with the download path's uniform failure logging; None on exception or non-200.""" + try: + resp = await self._http_client.get(url, headers=headers) + except Exception: + logger.exception("[whatsapp_cloud] media %s fetch raised (id=%s)", what, media_id) + return None + if resp.status_code != 200: + logger.warning("[whatsapp_cloud] media %s fetch failed (id=%s, status=%d)", what, media_id, resp.status_code) + return None + return resp + async def _download_media_to_cache( self, media_id: str, *, ext_hint: Optional[str] = None, ) -> tuple[Optional[str], Optional[str]]: @@ -887,18 +805,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): logger.warning("[whatsapp_cloud] refusing malformed media id %r", media_id[:64]) return None, None headers = self._auth_headers(json_body=False) - try: - meta_resp = await self._http_client.get( - f"{GRAPH_API_BASE}/{self._api_version}/{media_id}", headers=headers - ) - except Exception: - logger.exception("[whatsapp_cloud] media metadata fetch raised (id=%s)", media_id) - return None, None - if meta_resp.status_code != 200: - logger.warning( - "[whatsapp_cloud] media metadata fetch failed (id=%s, status=%d)", - media_id, meta_resp.status_code, - ) + meta_resp = await self._graph_get(f"{GRAPH_API_BASE}/{self._api_version}/{media_id}", headers, "metadata", media_id) + if meta_resp is None: return None, None try: meta = meta_resp.json() @@ -908,26 +816,12 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): mime = meta.get("mime_type") or "" if not temp_url: return None, None - # Auth is required even though the URL is signed (Meta documents this). - try: - blob_resp = await self._http_client.get(temp_url, headers=headers) - except Exception: - logger.exception("[whatsapp_cloud] media bytes fetch raised (id=%s)", media_id) + blob_resp = await self._graph_get(temp_url, headers, "bytes", media_id) + if blob_resp is None: return None, None - if blob_resp.status_code != 200: - logger.warning( - "[whatsapp_cloud] media bytes fetch failed (id=%s, status=%d)", - media_id, blob_resp.status_code, - ) - return None, None - ext = ext_hint - if not ext and mime: - ext = _ext_for_mime(mime) - if not ext: - ext = ".bin" _INBOUND_MEDIA_CACHE.mkdir(parents=True, exist_ok=True) - out_path = _INBOUND_MEDIA_CACHE / f"{media_id}{ext}" + out_path = _INBOUND_MEDIA_CACHE / f"{media_id}{ext_hint or _ext_for_mime(mime) or '.bin'}" try: out_path.write_bytes(blob_resp.content) except OSError: @@ -937,20 +831,18 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # ------------------------------------------------------------------ inbound async def _handle_health(self, request: "web.Request") -> "web.Response": - return web.json_response( - { - "status": "ok", - "platform": self.platform.value, - "phone_number_id": self._phone_number_id, - "webhook_path": self._webhook_path, - "verify_token_configured": bool(self._verify_token), - "app_secret_configured": bool(self._app_secret), - "ffmpeg_present": _FFMPEG_PATH is not None, - "accepted": self._accepted_count, - "duplicates": self._duplicate_count, - "rejected_signature": self._rejected_signature_count, - } - ) + return web.json_response({ + "status": "ok", + "platform": self.platform.value, + "phone_number_id": self._phone_number_id, + "webhook_path": self._webhook_path, + "verify_token_configured": bool(self._verify_token), + "app_secret_configured": bool(self._app_secret), + "ffmpeg_present": _FFMPEG_PATH is not None, + "accepted": self._accepted_count, + "duplicates": self._duplicate_count, + "rejected_signature": self._rejected_signature_count, + }) async def _handle_verify(self, request: "web.Request") -> "web.Response": """Meta subscription handshake: echo ``hub.challenge`` iff mode is @@ -958,14 +850,12 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if not self._verify_token: # Refuse rather than accept any token, which would let an attacker subscribe. return web.Response(status=503, text="verify_token not configured") - mode = request.query.get("hub.mode", "") - token = request.query.get("hub.verify_token", "") - challenge = request.query.get("hub.challenge", "") - if mode != "subscribe": + if request.query.get("hub.mode", "") != "subscribe": return web.Response(status=400, text="bad mode") # Compare as bytes: compare_digest raises TypeError on non-ASCII str. - if not hmac.compare_digest(token.encode(), self._verify_token.encode()): + if not hmac.compare_digest(request.query.get("hub.verify_token", "").encode(), self._verify_token.encode()): return web.Response(status=403, text="verify_token mismatch") + challenge = request.query.get("hub.challenge", "") if not challenge: return web.Response(status=400, text="missing challenge") return web.Response(text=challenge, content_type="text/plain") @@ -985,7 +875,6 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return web.Response(status=413) except Exception: return web.Response(status=400) - # Without app_secret the sender can't be authenticated → refuse (same # posture as the GET handshake refusing without verify_token). if not self._app_secret: @@ -998,8 +887,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if not self._verify_signature(raw, signature_header): self._rejected_signature_count += 1 logger.warning( - "[whatsapp_cloud] rejected webhook: invalid X-Hub-Signature-256 " - "(header=%r, body_len=%d)", + "[whatsapp_cloud] rejected webhook: invalid X-Hub-Signature-256 (header=%r, body_len=%d)", signature_header, len(raw), ) return web.Response(status=401) @@ -1013,12 +901,9 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): await self._dispatch_payload(payload) return web.Response(status=200) - # ------------------------------------------------------------------ signature def _verify_signature(self, raw_body: bytes, header: str) -> bool: """Verify ``sha256=`` HMAC of the raw body keyed by ``app_secret`` (constant-time).""" - if not self._app_secret or not header: - return False - if not header.startswith("sha256="): + if not self._app_secret or not header or not header.startswith("sha256="): return False expected_hex = header[len("sha256="):].strip() if not expected_hex: @@ -1050,50 +935,45 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): logger.debug("[whatsapp_cloud] ignoring non-WABA payload (object=%r)", payload.get("object")) return for entry in payload.get("entry") or []: - if not isinstance(entry, dict): - continue - for change in entry.get("changes") or []: - if not isinstance(change, dict): - continue - if change.get("field") != "messages": + for change in (entry.get("changes") or []) if isinstance(entry, dict) else []: + if not isinstance(change, dict) or change.get("field") != "messages": continue # account_alerts, template_status_update, … — not message ingress value = change.get("value") or {} - metadata = value.get("metadata") or {} - contacts_by_waid: Dict[str, str] = {} - for contact in value.get("contacts") or []: - if not isinstance(contact, dict): - continue - wa_id = str(contact.get("wa_id") or "").strip() - profile = contact.get("profile") or {} - name = str(profile.get("name") or "").strip() - if wa_id: - contacts_by_waid[wa_id] = name + contacts_by_waid = { + wa_id: str((contact.get("profile") or {}).get("name") or "").strip() + for contact in value.get("contacts") or [] + if isinstance(contact, dict) and (wa_id := str(contact.get("wa_id") or "").strip()) + } for raw_message in value.get("messages") or []: - if not isinstance(raw_message, dict): - continue - wamid = str(raw_message.get("id") or "").strip() - if not self._dedup_wamid(wamid): - logger.debug("[whatsapp_cloud] duplicate wamid %s, skipping", wamid) - continue - # Neither build nor dispatch errors may bubble out: the wamid is - # already dedup-marked, so a 500 would make Meta retry the batch - # and every message in it would then be dropped as a duplicate. - try: - event = await self._build_message_event_from_cloud(raw_message, contacts_by_waid, metadata) - except Exception: - logger.exception("[whatsapp_cloud] failed to build event for wamid %s", wamid) - continue - if event is None: - continue - self._accepted_count += 1 - try: - await self.handle_message(event) - except Exception: - logger.exception("[whatsapp_cloud] handle_message raised for wamid %s", wamid) + if isinstance(raw_message, dict): + await self._ingest_message(raw_message, contacts_by_waid, value.get("metadata") or {}) for status in value.get("statuses") or []: if isinstance(status, dict): logger.debug("[whatsapp_cloud] status %s for %s", status.get("status"), status.get("id")) + async def _ingest_message( + self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str], metadata: Dict[str, Any], + ) -> None: + """Dedup → build event → handle_message. Neither build nor dispatch errors may + bubble out: the wamid is already dedup-marked, so a 500 would make Meta retry + the batch and every message in it would then be dropped as a duplicate.""" + wamid = str(raw_message.get("id") or "").strip() + if not self._dedup_wamid(wamid): + logger.debug("[whatsapp_cloud] duplicate wamid %s, skipping", wamid) + return + try: + event = await self._build_message_event_from_cloud(raw_message, contacts_by_waid, metadata) + except Exception: + logger.exception("[whatsapp_cloud] failed to build event for wamid %s", wamid) + return + if event is None: + return + self._accepted_count += 1 + try: + await self.handle_message(event) + except Exception: + logger.exception("[whatsapp_cloud] handle_message raised for wamid %s", wamid) + async def _dispatch_interactive_reply( self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str], ) -> bool: @@ -1102,29 +982,23 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): True = claimed. False (unknown prefix, no live state, or no waiter) makes the caller fall back to text dispatch with the button title — covers stale taps. """ - inter = raw_message.get("interactive") or {} - # button_reply (type=button) and list_reply (type=list) carry id+title. - inner = inter.get("button_reply") or inter.get("list_reply") or {} + inner = _interactive_inner(raw_message) button_id = str(inner.get("id") or "").strip() if not button_id: return False sender_id = str(raw_message.get("from") or "").strip() if not self._is_interactive_sender_authorized(sender_id): logger.warning( - "[whatsapp_cloud] Rejected unauthorized interactive tap " - "from %s (button_id=%r)", + "[whatsapp_cloud] Rejected unauthorized interactive tap from %s (button_id=%r)", sender_id or "", button_id, ) return True # claim so the tap isn't re-dispatched as plain text parts = button_id.split(":", 2) - reply_target = str(raw_message.get("from") or "") - for prefix, handler in self._INTERACTIVE_HANDLERS.items(): - if button_id.startswith(prefix): - if len(parts) != 3: - return False - return await handler(self, reply_target, inner, parts) + handler = next((h for prefix, h in self._INTERACTIVE_HANDLERS.items() if button_id.startswith(prefix)), None) # Unknown prefix (maybe a plugin-defined adapter's) — text dispatch is the safe default. - return False + if handler is None or len(parts) != 3: + return False + return await handler(self, str(raw_message.get("from") or ""), inner, parts) async def _reply_best_effort(self, to: str, text: str, fail_log: str) -> None: try: @@ -1132,23 +1006,26 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): except Exception: logger.exception(fail_log) + @staticmethod + def _pop_tap_state(state: "OrderedDict[str, str]", key: str, stale_log: str) -> Optional[str]: + """Pop the session_key for a tapped prompt; log (info) when nothing is live — likely a stale tap.""" + session_key = state.pop(key, None) + if not session_key: + logger.info(stale_log, key) + return session_key + async def _handle_clarify_tap(self, to: str, inner: Dict[str, Any], parts: list) -> bool: _, clarify_id, choice = parts - session_key = self._clarify_state.pop(clarify_id, None) + session_key = self._pop_tap_state( + self._clarify_state, clarify_id, + "[whatsapp_cloud] clarify tap with no matching state (clarify_id=%s) — likely stale; falling back to text", + ) if not session_key: - logger.info( - "[whatsapp_cloud] clarify tap with no matching state " - "(clarify_id=%s) — likely stale; falling back to text", - clarify_id, - ) return False try: from tools.clarify_gateway import resolve_gateway_clarify except ImportError: - logger.warning( - "[whatsapp_cloud] clarify resolver unavailable; " - "falling back to text dispatch" - ) + logger.warning("[whatsapp_cloud] clarify resolver unavailable; falling back to text dispatch") return False if choice == "other": # Flip the entry into text-capture mode so the gateway's text intercept @@ -1164,8 +1041,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if not flipped: # Entry vanished (timeout, /new, restart) — fall through to text. logger.info( - "[whatsapp_cloud] clarify 'Other' tap but entry " - "missing (clarify_id=%s); falling back to text", + "[whatsapp_cloud] clarify 'Other' tap but entry missing (clarify_id=%s); falling back to text", clarify_id, ) return False @@ -1180,25 +1056,20 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self._clarify_state[clarify_id] = session_key # a follow-up text can still resolve return False # Title is the numeric label; the agent has the prompt in context to interpret it. - response_text = str(inner.get("title") or str(idx + 1)) - resolved = resolve_gateway_clarify(clarify_id, response_text) - if not resolved: + if not resolve_gateway_clarify(clarify_id, str(inner.get("title") or str(idx + 1))): logger.info( - "[whatsapp_cloud] clarify resolver reported no waiter " - "(clarify_id=%s) — falling back to text", clarify_id, + "[whatsapp_cloud] clarify resolver reported no waiter (clarify_id=%s) — falling back to text", clarify_id, ) return False return True async def _handle_approval_tap(self, to: str, inner: Dict[str, Any], parts: list) -> bool: _, approval_id, choice = parts - session_key = self._exec_approval_state.pop(approval_id, None) + session_key = self._pop_tap_state( + self._exec_approval_state, approval_id, + "[whatsapp_cloud] approval tap with no matching state (approval_id=%s) — likely stale; falling back to text", + ) if not session_key: - logger.info( - "[whatsapp_cloud] approval tap with no matching state " - "(approval_id=%s) — likely stale; falling back to text", - approval_id, - ) return False if choice not in ("approve", "deny"): self._exec_approval_state[approval_id] = session_key @@ -1209,32 +1080,26 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): logger.warning("[whatsapp_cloud] approval resolver unavailable") return False count = resolve_gateway_approval(session_key, choice) - if not count: - logger.info( - "[whatsapp_cloud] approval resolver reported no waiter " - "(session_key=%s) — likely already resolved", - session_key, - ) # A tap after the wait timed out (count == 0) must not claim approval: # the command was already denied fail-closed. if count: confirm_text = "✅ Approved." if choice == "approve" else "❌ Denied." else: - confirm_text = ( - "⌛ Approval expired — command was not run " - "(already timed out or resolved elsewhere)." + logger.info( + "[whatsapp_cloud] approval resolver reported no waiter (session_key=%s) — likely already resolved", + session_key, ) + confirm_text = "⌛ Approval expired — command was not run (already timed out or resolved elsewhere)." await self._reply_best_effort(to, confirm_text, "[whatsapp_cloud] approval confirm failed") return True async def _handle_slash_confirm_tap(self, to: str, inner: Dict[str, Any], parts: list) -> bool: _, choice, confirm_id = parts - session_key = self._slash_confirm_state.pop(confirm_id, None) + session_key = self._pop_tap_state( + self._slash_confirm_state, confirm_id, + "[whatsapp_cloud] slash_confirm tap with no matching state (confirm_id=%s) — likely stale", + ) if not session_key: - logger.info( - "[whatsapp_cloud] slash_confirm tap with no matching state " - "(confirm_id=%s) — likely stale", confirm_id, - ) return False if choice not in ("once", "always", "cancel"): self._slash_confirm_state[confirm_id] = session_key @@ -1270,8 +1135,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): inbound_mime = str(inner.get("mime_type") or "").strip() if not media_id: return media_urls, media_types, body - ext_hint = _ext_for_mime(inbound_mime) if inbound_mime else None - local_path, dl_mime = await self._download_media_to_cache(media_id, ext_hint=ext_hint) + local_path, dl_mime = await self._download_media_to_cache(media_id, ext_hint=_ext_for_mime(inbound_mime)) if local_path: media_urls.append(local_path) media_types.append(dl_mime or inbound_mime or "application/octet-stream") @@ -1298,8 +1162,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): file_size = Path(doc_path).stat().st_size if file_size > _MAX_TEXT_INJECT_BYTES: logger.info( - "[whatsapp_cloud] skipping text injection for %s " - "(%d bytes > %d)", + "[whatsapp_cloud] skipping text injection for %s (%d bytes > %d)", doc_path, file_size, _MAX_TEXT_INJECT_BYTES, ) continue @@ -1316,27 +1179,14 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): ) -> Optional[MessageEvent]: """Convert a Cloud-API message object into a MessageEvent, or None if gated out.""" msg_type_str = str(raw_message.get("type") or "text").lower() - # Button taps route to the gateway resolver BEFORE text dispatch — the # resolver unblocks the waiting agent, so don't also start a fresh turn. - if msg_type_str == "interactive": - if await self._dispatch_interactive_reply(raw_message, contacts_by_waid): - return None - body = "" - if msg_type_str == "text": - body = str((raw_message.get("text") or {}).get("body") or "") - elif msg_type_str == "button": - body = str((raw_message.get("button") or {}).get("text") or "") - elif msg_type_str == "interactive": - inter = raw_message.get("interactive") or {} - inner = inter.get("button_reply") or inter.get("list_reply") or {} - body = str(inner.get("title") or "") - elif msg_type_str in _INBOUND_MEDIA_KINDS: - body = str((raw_message.get(msg_type_str) or {}).get("caption") or "") - message_type = _MESSAGE_TYPE_BY_KIND.get(msg_type_str, MessageType.TEXT) + if msg_type_str == "interactive" and await self._dispatch_interactive_reply(raw_message, contacts_by_waid): + return None + extract = _BODY_BY_KIND.get(msg_type_str) + body = str(extract(raw_message) or "") if extract else "" sender_id = str(raw_message.get("from") or "").strip() sender_name = contacts_by_waid.get(sender_id, "") - # DMs only: chat_id == sender wa_id. A ``chat`` field marks a group-shaped # payload (capability-gated by Meta) — refuse rather than treat as a DM. chat_field = raw_message.get("chat") @@ -1349,8 +1199,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): ) return None chat_id = sender_id - gating_data = {"chatId": chat_id, "senderId": sender_id, "isGroup": False, "body": body} - if not self._should_process_message(gating_data): + if not self._should_process_message({"chatId": chat_id, "senderId": sender_id, "isGroup": False, "body": body}): return None media_urls: list[str] = [] media_types: list[str] = [] @@ -1358,7 +1207,6 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): media_urls, media_types, body = await self._collect_inbound_media(msg_type_str, raw_message, body) if msg_type_str == "document" and media_urls: body = self._inject_document_text(media_urls, body) - # Meta's ``context`` gives only the quoted message's id (+ author), never its # text; resolve from rich_sent_store so run.py can build "[Replying to: ...]". context = raw_message.get("context") or {} @@ -1372,13 +1220,6 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): our_number = str(metadata.get("display_phone_number") or "").strip() if quoted_from and our_number: reply_to_is_own = quoted_from == our_number - source = self.build_source( - chat_id=chat_id, - chat_name=sender_name or chat_id, - chat_type="dm", - user_id=sender_id, - user_name=sender_name or None, - ) wamid = str(raw_message.get("id") or "") or None if wamid and chat_id: # Done AFTER gating so filtered messages don't leak typing/read receipts. @@ -1387,8 +1228,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): rich_sent_store.record(chat_id, wamid, body) return MessageEvent( text=body, - message_type=message_type, - source=source, + message_type=_MESSAGE_TYPE_BY_KIND.get(msg_type_str, MessageType.TEXT), + source=self.build_source( + chat_id=chat_id, chat_name=sender_name or chat_id, chat_type="dm", + user_id=sender_id, user_name=sender_name or None, + ), raw_message=raw_message, message_id=wamid, reply_to_message_id=reply_to_id, From f8d61e854182d0da294f50db175c0fed9b173df9 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:27:41 -0700 Subject: [PATCH 08/10] refactor(gateway/whatsapp): compact Cloud adapter tables, lifecycle, and log call sites --- gateway/platforms/whatsapp_cloud.py | 294 ++++++++-------------------- 1 file changed, 87 insertions(+), 207 deletions(-) diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index a5662ad931..af6dba7b6f 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -27,15 +27,12 @@ from typing import Any, Dict, Optional try: from aiohttp import web - AIOHTTP_AVAILABLE = True except ImportError: AIOHTTP_AVAILABLE = False web = None # type: ignore[assignment] - try: import httpx - HTTPX_AVAILABLE = True except ImportError: HTTPX_AVAILABLE = False @@ -69,20 +66,13 @@ INTERACTIVE_STATE_CACHE_SIZE = 1000 # round-tripping to Graph just to be rejected. # https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media _MEDIA_SIZE_LIMITS = { - "image": 5 * 1024 * 1024, - "video": 16 * 1024 * 1024, - "audio": 16 * 1024 * 1024, - "document": 100 * 1024 * 1024, - "sticker": 100 * 1024, + "image": 5 * 1024 * 1024, "video": 16 * 1024 * 1024, "audio": 16 * 1024 * 1024, + "document": 100 * 1024 * 1024, "sticker": 100 * 1024, } - # Default mime types when we can't guess from the path's extension. _DEFAULT_MIME = { - "image": "image/jpeg", - "video": "video/mp4", - "audio": "audio/mpeg", - "document": "application/octet-stream", - "sticker": "image/webp", + "image": "image/jpeg", "video": "video/mp4", "audio": "audio/mpeg", + "document": "application/octet-stream", "sticker": "image/webp", } # ``shutil.which`` honours PATHEXT on Windows. None → MP3 voice falls back to an @@ -93,32 +83,18 @@ _FFMPEG_PATH = shutil.which("ffmpeg") # audio/mp4 → .mp4, image/jpeg → .jpe). Downstream STT/vision whitelists the # in-the-wild forms, so pin the few Meta sends. _WHATSAPP_MIME_EXTENSION_OVERRIDES: Dict[str, str] = { - "audio/ogg": ".ogg", - "audio/x-opus+ogg": ".ogg", - "audio/opus": ".ogg", - "audio/mp4": ".m4a", - "audio/x-m4a": ".m4a", - "image/jpeg": ".jpg", + "audio/ogg": ".ogg", "audio/x-opus+ogg": ".ogg", "audio/opus": ".ogg", + "audio/mp4": ".m4a", "audio/x-m4a": ".m4a", "image/jpeg": ".jpg", } _INBOUND_MEDIA_KINDS = {"image", "video", "audio", "voice", "document", "sticker"} -_TEXT_INJECT_EXTS = { - ".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", - ".log", ".py", ".js", ".ts", ".html", ".css", -} +_TEXT_INJECT_EXTS = {".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log", ".py", ".js", ".ts", ".html", ".css"} _MAX_TEXT_INJECT_BYTES = 100 * 1024 # matches Telegram/Discord/Slack _MESSAGE_TYPE_BY_KIND = { - "text": MessageType.TEXT, - "image": MessageType.PHOTO, - "video": MessageType.VIDEO, - "audio": MessageType.VOICE, - "voice": MessageType.VOICE, - "document": MessageType.DOCUMENT, - "sticker": MessageType.PHOTO, - "button": MessageType.TEXT, - "interactive": MessageType.TEXT, - "location": MessageType.TEXT, - "contacts": MessageType.TEXT, + "text": MessageType.TEXT, "image": MessageType.PHOTO, "video": MessageType.VIDEO, + "audio": MessageType.VOICE, "voice": MessageType.VOICE, "document": MessageType.DOCUMENT, + "sticker": MessageType.PHOTO, "button": MessageType.TEXT, "interactive": MessageType.TEXT, + "location": MessageType.TEXT, "contacts": MessageType.TEXT, } _HTTP_PREFIXES = ("http://", "https://") @@ -231,16 +207,14 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # Webhook dedup state (in-memory, FIFO-evicted) and counters. self._seen_wamids: "OrderedDict[str, bool]" = OrderedDict() - self._duplicate_count: int = 0 - self._accepted_count: int = 0 - self._rejected_signature_count: int = 0 + self._duplicate_count = self._accepted_count = self._rejected_signature_count = 0 self._warned_no_ffmpeg: bool = False # Latest inbound wamid per chat: Meta's typing/read-receipt API needs a # message_id to attach to, and the base send_typing contract has none. self._last_inbound_wamid_by_chat: "OrderedDict[str, str]" = OrderedDict() - # Interactive-button state: short id (in the button payload) → session_key - # for the gateway resolver. Popped on tap; FIFO-capped via _bounded_put so - # ignored prompts don't accumulate (an evicted tap degrades to text fallback). + # Interactive-button state: short id (in the button payload) → session_key for + # the gateway resolver. Popped on tap; FIFO-capped via _bounded_put so ignored + # prompts don't accumulate (an evicted tap degrades to text fallback). self._clarify_state: "OrderedDict[str, str]" = OrderedDict() self._exec_approval_state: "OrderedDict[str, str]" = OrderedDict() self._slash_confirm_state: "OrderedDict[str, str]" = OrderedDict() @@ -301,15 +275,13 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if not check_whatsapp_cloud_requirements(): self._set_fatal_error( "whatsapp_cloud_deps_missing", - "aiohttp and httpx are required for whatsapp_cloud — reinstall hermes-agent.", - retryable=False, + "aiohttp and httpx are required for whatsapp_cloud — reinstall hermes-agent.", retryable=False, ) return False if not self._phone_number_id or not self._access_token: self._set_fatal_error( "whatsapp_cloud_unconfigured", - "WHATSAPP_CLOUD_PHONE_NUMBER_ID and WHATSAPP_CLOUD_ACCESS_TOKEN are required.", - retryable=False, + "WHATSAPP_CLOUD_PHONE_NUMBER_ID and WHATSAPP_CLOUD_ACCESS_TOKEN are required.", retryable=False, ) return False # Tighter keepalive so idle CLOSE_WAIT drains promptly. @@ -330,31 +302,26 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): ) if not self._verify_token: logger.warning( - "[whatsapp_cloud] WHATSAPP_CLOUD_VERIFY_TOKEN is not set — " - "the GET subscription handshake will fail until it is." + "[whatsapp_cloud] WHATSAPP_CLOUD_VERIFY_TOKEN is not set — the GET subscription handshake will fail until it is." ) if not self._app_secret: logger.warning( - "[whatsapp_cloud] WHATSAPP_CLOUD_APP_SECRET is not set — " - "incoming webhook POSTs will be refused with 503. Set " - "the app secret to enable inbound message delivery." + "[whatsapp_cloud] WHATSAPP_CLOUD_APP_SECRET is not set — incoming webhook POSTs will be refused " + "with 503. Set the app secret to enable inbound message delivery." ) self._wire_plugin_handlers(None) return True async def disconnect(self) -> None: - if self._runner is not None: + for attr, close, what in (("_runner", "cleanup", "webhook server cleanup"), ("_http_client", "aclose", "http client close")): + obj = getattr(self, attr) + if obj is None: + continue try: - await self._runner.cleanup() + await getattr(obj, close)() except Exception: - logger.exception("[whatsapp_cloud] webhook server cleanup failed") - self._runner = None - if self._http_client is not None: - try: - await self._http_client.aclose() - except Exception: - logger.exception("[whatsapp_cloud] http client close failed") - self._http_client = None + logger.exception("[whatsapp_cloud] %s failed", what) + setattr(self, attr, None) self._mark_disconnected() # ------------------------------------------------------------------ outbound @@ -449,23 +416,13 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # ------------------------------------------------------------------ typing indicator + read receipts async def send_typing(self, chat_id: str, metadata=None) -> None: - """Mark the latest inbound message read AND show a typing indicator. - - Meta couples both into one POST (``status: "read"`` + ``typing_indicator``); - the indicator auto-dismisses on reply or after 25s. Best-effort: every error - is swallowed so the main reply path is never blocked. - """ - if self._http_client is None: - return + """Mark the latest inbound message read AND show a typing indicator. Meta couples + both into one POST; the indicator auto-dismisses on reply or after 25s. Best-effort: + every error is swallowed so the main reply path is never blocked.""" wamid = self._last_inbound_wamid_by_chat.get(chat_id) - if not wamid: - return # no inbound yet for this chat (or cache cleared on restart) - payload = { - "messaging_product": "whatsapp", - "status": "read", - "message_id": wamid, - "typing_indicator": {"type": "text"}, - } + if self._http_client is None or not wamid: + return # not connected, or no inbound yet for this chat (cache cleared on restart) + payload = {"messaging_product": "whatsapp", "status": "read", "message_id": wamid, "typing_indicator": {"type": "text"}} try: resp = await self._http_client.post(self._graph_url("messages"), headers=self._auth_headers(), json=payload) except Exception: @@ -484,11 +441,9 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): logger.debug("[whatsapp_cloud] typing/read indicator returned %d (%s)", resp.status_code, code) # ------------------------------------------------------------------ interactive messages - # - # ``interactive.type=button``: ≤3 quick-reply buttons (id ≤256 chars, title ≤20). - # ``interactive.type=list``: one "Tap to choose" button opening ≤10 rows. - # Free-form (no Meta approval) but only valid inside the 24h window — fine, since - # all senders here fire in direct response to a user message. + # ``type=button``: ≤3 quick-reply buttons (id ≤256 chars, title ≤20); ``type=list``: one + # "Tap to choose" button opening ≤10 rows. Free-form (no Meta approval) but only valid + # inside the 24h window — fine, since all senders here fire in reply to a user message. async def _post_interactive( self, chat_id: str, interactive_body: Dict[str, Any], reply_to: Optional[str] = None, @@ -557,11 +512,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): else: # List rows: id + title (≤24) + description (≤72) with the choice text. rows = [ - { - "id": f"cl:{clarify_id}:{idx}", - "title": self._truncate_button_label(f"{idx + 1}", limit=24), - "description": self._truncate_button_label(choice_text, limit=72), - } + {"id": f"cl:{clarify_id}:{idx}", "title": self._truncate_button_label(f"{idx + 1}", limit=24), + "description": self._truncate_button_label(choice_text, limit=72)} for idx, choice_text in enumerate(choices_list) ] rows.append({"id": f"cl:{clarify_id}:other", "title": "✏️ Other", "description": "Type your own answer"}) @@ -583,9 +535,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): cmd = command or "" cmd_preview = cmd if len(cmd) <= 800 else cmd[:800] + "..." body_text = self._truncate_body( - f"⚠️ *Command Approval Required*\n\n" - f"```\n{cmd_preview}\n```\n\n" - f"Reason: {description}" + f"⚠️ *Command Approval Required*\n\n```\n{cmd_preview}\n```\n\nReason: {description}" + ("\n\nSmart DENY: owner override applies to this one operation only." if smart_denied else "") ) approval_id = uuid.uuid4().hex[:12] @@ -631,14 +581,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): mime_type = mime_type or mimetypes.guess_type(file_path)[0] or _DEFAULT_MIME.get(media_kind, "application/octet-stream") try: with open(file_path, "rb") as fh: - files = { - "file": (os.path.basename(file_path), fh, mime_type), - "messaging_product": (None, "whatsapp"), - "type": (None, mime_type), - } - resp = await self._http_client.post( - self._graph_url("media"), headers=self._auth_headers(json_body=False), files=files - ) + files = {"file": (os.path.basename(file_path), fh, mime_type), "messaging_product": (None, "whatsapp"), "type": (None, mime_type)} + resp = await self._http_client.post(self._graph_url("media"), headers=self._auth_headers(json_body=False), files=files) except Exception as exc: logger.exception("[whatsapp_cloud] media upload failed") return None, str(exc) @@ -655,10 +599,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): media_link: Optional[str] = None, caption: Optional[str] = None, filename: Optional[str] = None, reply_to: Optional[str] = None, ) -> SendResult: - """POST a media message referencing exactly one of an uploaded ``media_id`` or a public ``link``. - - Caption is accepted on image/video/document; filename on document only. - """ + """POST a media message referencing exactly one of an uploaded ``media_id`` or a public + ``link``. Caption is accepted on image/video/document; filename on document only.""" if self._http_client is None: return SendResult(success=False, error="Not connected") if bool(media_id) == bool(media_link): @@ -715,12 +657,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): """Voice message: ``audio/ogg; codecs=opus`` renders as a voice bubble, so a local MP3 (Hermes TTS output) is converted via ffmpeg first; other audio is sent as-is.""" mime_type: Optional[str] = None - is_local_mp3 = ( - not audio_path.startswith(_HTTP_PREFIXES) - and audio_path.lower().endswith(".mp3") - and os.path.exists(audio_path) - ) - if is_local_mp3: + if not audio_path.startswith(_HTTP_PREFIXES) and audio_path.lower().endswith(".mp3") and os.path.exists(audio_path): opus_path = await self._convert_to_opus(audio_path) if opus_path: try: @@ -741,20 +678,15 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): ) async def _convert_to_opus(self, mp3_path: str) -> Optional[str]: - """MP3 → ``audio/ogg; codecs=opus``; None if ffmpeg is missing or fails. - - ``-application voip`` tunes for speech; ``-b:a 32k -vbr on`` matches - WhatsApp's native voice-note bitrate. - """ + """MP3 → ``audio/ogg; codecs=opus``; None if ffmpeg is missing or fails. ``-application voip`` + tunes for speech; ``-b:a 32k -vbr on`` matches WhatsApp's native voice-note bitrate.""" if not _FFMPEG_PATH: if not self._warned_no_ffmpeg: self._warned_no_ffmpeg = True logger.warning( - "[whatsapp_cloud] ffmpeg not found on PATH — voice messages will " - "be delivered as MP3 audio attachments instead of native voice " - "notes (green waveform bubble). Install ffmpeg to enable: " - "Windows `winget install Gyan.FFmpeg`, macOS `brew install ffmpeg`, " - "Linux package manager." + "[whatsapp_cloud] ffmpeg not found on PATH — voice messages will be delivered as MP3 audio " + "attachments instead of native voice notes (green waveform bubble). Install ffmpeg to enable: " + "Windows `winget install Gyan.FFmpeg`, macOS `brew install ffmpeg`, Linux package manager." ) return None out_path = mp3_path.rsplit(".", 1)[0] + ".ogg" @@ -793,9 +725,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self, media_id: str, *, ext_hint: Optional[str] = None, ) -> tuple[Optional[str], Optional[str]]: """Two-step Graph download: ``GET /`` → signed temp URL (~5 min) → bytes. - - Returns ``(local_path, mime_type)`` or ``(None, None)`` on any failure (logged). - """ + Returns ``(local_path, mime_type)`` or ``(None, None)`` on any failure (logged).""" if self._http_client is None: return None, None # media_id is interpolated into a Graph URL and a cache filename — refuse @@ -812,8 +742,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): meta = meta_resp.json() except Exception: return None, None - temp_url = meta.get("url") - mime = meta.get("mime_type") or "" + temp_url, mime = meta.get("url"), meta.get("mime_type") or "" if not temp_url: return None, None # Auth is required even though the URL is signed (Meta documents this). @@ -832,15 +761,10 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # ------------------------------------------------------------------ inbound async def _handle_health(self, request: "web.Request") -> "web.Response": return web.json_response({ - "status": "ok", - "platform": self.platform.value, - "phone_number_id": self._phone_number_id, - "webhook_path": self._webhook_path, - "verify_token_configured": bool(self._verify_token), - "app_secret_configured": bool(self._app_secret), - "ffmpeg_present": _FFMPEG_PATH is not None, - "accepted": self._accepted_count, - "duplicates": self._duplicate_count, + "status": "ok", "platform": self.platform.value, "phone_number_id": self._phone_number_id, + "webhook_path": self._webhook_path, "verify_token_configured": bool(self._verify_token), + "app_secret_configured": bool(self._app_secret), "ffmpeg_present": _FFMPEG_PATH is not None, + "accepted": self._accepted_count, "duplicates": self._duplicate_count, "rejected_signature": self._rejected_signature_count, }) @@ -861,14 +785,10 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return web.Response(text=challenge, content_type="text/plain") async def _handle_webhook(self, request: "web.Request") -> "web.Response": - """Inbound webhook POST: raw bytes → HMAC verify → JSON → dispatch. - - Signature is over the raw body, so JSON parsing must come after - verification. Always 200 once a valid request is ack'd — Meta retries - non-200 for up to 7 days and would multiply downstream agent work. - """ - # Read one byte past Meta's 3MB max so oversized chunked bodies are - # rejected before buffering the rest. + """Inbound webhook POST: raw bytes → HMAC verify → JSON → dispatch. Signature is over + the raw body, so JSON parsing must come after verification. Always 200 once a valid + request is ack'd — Meta retries non-200 for up to 7 days and would multiply agent work.""" + # Read one byte past Meta's 3MB max so oversized chunked bodies are rejected before buffering. try: raw = await _read_limited_request_body(request, WEBHOOK_MAX_BODY_BYTES) except ValueError: @@ -878,24 +798,18 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # Without app_secret the sender can't be authenticated → refuse (same # posture as the GET handshake refusing without verify_token). if not self._app_secret: - logger.error( - "[whatsapp_cloud] webhook POST refused: app_secret unset. " - "Set WHATSAPP_CLOUD_APP_SECRET to enable inbound delivery." - ) + logger.error("[whatsapp_cloud] webhook POST refused: app_secret unset. Set WHATSAPP_CLOUD_APP_SECRET to enable inbound delivery.") return web.Response(status=503, text="app_secret not configured") signature_header = request.headers.get("X-Hub-Signature-256", "") if not self._verify_signature(raw, signature_header): self._rejected_signature_count += 1 - logger.warning( - "[whatsapp_cloud] rejected webhook: invalid X-Hub-Signature-256 (header=%r, body_len=%d)", - signature_header, len(raw), - ) + logger.warning("[whatsapp_cloud] rejected webhook: invalid X-Hub-Signature-256 (header=%r, body_len=%d)", signature_header, len(raw)) return web.Response(status=401) try: payload = json.loads(raw) except Exception: logger.warning("[whatsapp_cloud] webhook body is not valid JSON") - return web.Response(status=400) + payload = None if not isinstance(payload, dict): return web.Response(status=400) await self._dispatch_payload(payload) @@ -927,10 +841,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): async def _dispatch_payload(self, payload: Dict[str, Any]) -> None: """Walk ``entry[].changes[].value.{messages, contacts, statuses}`` and dispatch each message. - - ``statuses`` (sent/delivered/read/failed) are logged at debug, not - dispatched — the agent doesn't consume delivery receipts. - """ + ``statuses`` (sent/delivered/read/failed) are only logged — the agent doesn't consume receipts.""" if payload.get("object") != "whatsapp_business_account": logger.debug("[whatsapp_cloud] ignoring non-WABA payload (object=%r)", payload.get("object")) return @@ -977,21 +888,16 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): async def _dispatch_interactive_reply( self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str], ) -> bool: - """Route an inbound button tap to its resolver (see ``_INTERACTIVE_HANDLERS``). - - True = claimed. False (unknown prefix, no live state, or no waiter) makes the - caller fall back to text dispatch with the button title — covers stale taps. - """ + """Route an inbound button tap to its resolver (see ``_INTERACTIVE_HANDLERS``). True = claimed. + False (unknown prefix, no live state, or no waiter) makes the caller fall back to text + dispatch with the button title — covers stale taps.""" inner = _interactive_inner(raw_message) button_id = str(inner.get("id") or "").strip() if not button_id: return False sender_id = str(raw_message.get("from") or "").strip() if not self._is_interactive_sender_authorized(sender_id): - logger.warning( - "[whatsapp_cloud] Rejected unauthorized interactive tap from %s (button_id=%r)", - sender_id or "", button_id, - ) + logger.warning("[whatsapp_cloud] Rejected unauthorized interactive tap from %s (button_id=%r)", sender_id or "", button_id) return True # claim so the tap isn't re-dispatched as plain text parts = button_id.split(":", 2) handler = next((h for prefix, h in self._INTERACTIVE_HANDLERS.items() if button_id.startswith(prefix)), None) @@ -1040,10 +946,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): flipped = False if not flipped: # Entry vanished (timeout, /new, restart) — fall through to text. - logger.info( - "[whatsapp_cloud] clarify 'Other' tap but entry missing (clarify_id=%s); falling back to text", - clarify_id, - ) + logger.info("[whatsapp_cloud] clarify 'Other' tap but entry missing (clarify_id=%s); falling back to text", clarify_id) return False # Keep the mapping live for further taps on the same prompt. self._clarify_state[clarify_id] = session_key @@ -1057,9 +960,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return False # Title is the numeric label; the agent has the prompt in context to interpret it. if not resolve_gateway_clarify(clarify_id, str(inner.get("title") or str(idx + 1))): - logger.info( - "[whatsapp_cloud] clarify resolver reported no waiter (clarify_id=%s) — falling back to text", clarify_id, - ) + logger.info("[whatsapp_cloud] clarify resolver reported no waiter (clarify_id=%s) — falling back to text", clarify_id) return False return True @@ -1085,10 +986,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if count: confirm_text = "✅ Approved." if choice == "approve" else "❌ Denied." else: - logger.info( - "[whatsapp_cloud] approval resolver reported no waiter (session_key=%s) — likely already resolved", - session_key, - ) + logger.info("[whatsapp_cloud] approval resolver reported no waiter (session_key=%s) — likely already resolved", session_key) confirm_text = "⌛ Approval expired — command was not run (already timed out or resolved elsewhere)." await self._reply_best_effort(to, confirm_text, "[whatsapp_cloud] approval confirm failed") return True @@ -1142,15 +1040,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): logger.info("[whatsapp_cloud] cached inbound %s media: %s", msg_type_str, local_path) else: logger.warning( - "[whatsapp_cloud] failed to download inbound %s (id=%s) — " - "agent will see message metadata but not the binary", + "[whatsapp_cloud] failed to download inbound %s (id=%s) — agent will see message metadata but not the binary", msg_type_str, media_id, ) - if msg_type_str == "document": - fname = str(inner.get("filename") or "").strip() - if fname and not body: - body = f"[Document: {fname}]" - return media_urls, media_types, body + fname = str(inner.get("filename") or "").strip() if msg_type_str == "document" else "" + return media_urls, media_types, body or (f"[Document: {fname}]" if fname else body) @staticmethod def _inject_document_text(media_urls: list[str], body: str) -> str: @@ -1161,10 +1055,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): try: file_size = Path(doc_path).stat().st_size if file_size > _MAX_TEXT_INJECT_BYTES: - logger.info( - "[whatsapp_cloud] skipping text injection for %s (%d bytes > %d)", - doc_path, file_size, _MAX_TEXT_INJECT_BYTES, - ) + logger.info("[whatsapp_cloud] skipping text injection for %s (%d bytes > %d)", doc_path, file_size, _MAX_TEXT_INJECT_BYTES) continue content = Path(doc_path).read_text(encoding="utf-8", errors="replace") injection = f"[Content of {Path(doc_path).name}]:\n{content}" @@ -1189,13 +1080,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): sender_name = contacts_by_waid.get(sender_id, "") # DMs only: chat_id == sender wa_id. A ``chat`` field marks a group-shaped # payload (capability-gated by Meta) — refuse rather than treat as a DM. - chat_field = raw_message.get("chat") - if chat_field: + if raw_message.get("chat"): logger.warning( - "[whatsapp_cloud] received group-shaped message (chat=%s, " - "wamid=%s) — group support is not yet implemented; dropping. " - "Use the Baileys whatsapp adapter for group chats.", - chat_field, raw_message.get("id"), + "[whatsapp_cloud] received group-shaped message (chat=%s, wamid=%s) — group support is not yet " + "implemented; dropping. Use the Baileys whatsapp adapter for group chats.", + raw_message.get("chat"), raw_message.get("id"), ) return None chat_id = sender_id @@ -1211,15 +1100,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # text; resolve from rich_sent_store so run.py can build "[Replying to: ...]". context = raw_message.get("context") or {} reply_to_id = str(context.get("id") or "").strip() or None - reply_to_text: Optional[str] = None - reply_to_is_own = False - if reply_to_id: - reply_to_text = rich_sent_store.lookup(chat_id, reply_to_id) - # context.from == our business number → user replied to the bot. - quoted_from = str(context.get("from") or "").strip() - our_number = str(metadata.get("display_phone_number") or "").strip() - if quoted_from and our_number: - reply_to_is_own = quoted_from == our_number + reply_to_text = rich_sent_store.lookup(chat_id, reply_to_id) if reply_to_id else None + # context.from == our business number → user replied to the bot. + quoted_from = str(context.get("from") or "").strip() + our_number = str(metadata.get("display_phone_number") or "").strip() + reply_to_is_own = bool(reply_to_id and quoted_from and our_number) and quoted_from == our_number wamid = str(raw_message.get("id") or "") or None if wamid and chat_id: # Done AFTER gating so filtered messages don't leak typing/read receipts. @@ -1227,17 +1112,12 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if body: rich_sent_store.record(chat_id, wamid, body) return MessageEvent( - text=body, - message_type=_MESSAGE_TYPE_BY_KIND.get(msg_type_str, MessageType.TEXT), + text=body, message_type=_MESSAGE_TYPE_BY_KIND.get(msg_type_str, MessageType.TEXT), source=self.build_source( chat_id=chat_id, chat_name=sender_name or chat_id, chat_type="dm", user_id=sender_id, user_name=sender_name or None, ), - raw_message=raw_message, - message_id=wamid, - reply_to_message_id=reply_to_id, - reply_to_text=reply_to_text, - reply_to_is_own_message=reply_to_is_own, - media_urls=media_urls, - media_types=media_types, + raw_message=raw_message, message_id=wamid, reply_to_message_id=reply_to_id, + reply_to_text=reply_to_text, reply_to_is_own_message=reply_to_is_own, + media_urls=media_urls, media_types=media_types, ) From ce1f7cb5dbeaaae69fea1332d5f202527a727fe7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:34:27 -0700 Subject: [PATCH 09/10] refactor(gateway/whatsapp): connect precondition table, lazy resolver import helper, merged interactive post --- gateway/platforms/whatsapp_cloud.py | 251 +++++++++++----------------- 1 file changed, 95 insertions(+), 156 deletions(-) diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index af6dba7b6f..c4d2b3dd56 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -1,19 +1,18 @@ -""" -WhatsApp Cloud API adapter — official Meta WhatsApp Business Platform (Graph API + +"""WhatsApp Cloud API adapter — official Meta WhatsApp Business Platform (Graph API + aiohttp webhook). Complements the Baileys bridge plugin; both share gating / mention / formatting behavior via ``WhatsAppBehaviorMixin``. Required env: WHATSAPP_CLOUD_PHONE_NUMBER_ID, WHATSAPP_CLOUD_ACCESS_TOKEN. Optional: WHATSAPP_CLOUD_APP_ID, _APP_SECRET (HMAC key for X-Hub-Signature-256), _WABA_ID, _VERIFY_TOKEN (hub.verify_token), _WEBHOOK_HOST (unset → dual-stack all interfaces), -_WEBHOOK_PORT (8090), _WEBHOOK_PATH (/whatsapp/webhook), _API_VERSION (v20.0). -""" +_WEBHOOK_PORT (8090), _WEBHOOK_PATH (/whatsapp/webhook), _API_VERSION (v20.0).""" from __future__ import annotations import asyncio import hashlib import hmac +import importlib import json import logging import mimetypes @@ -49,22 +48,19 @@ logger = logging.getLogger(__name__) DEFAULT_API_VERSION = "v20.0" -# ``None`` → aiohttp binds one socket per address family (IPv4 + IPv6). "0.0.0.0" -# bound IPv4 only and was unreachable on IPv6-only networks (e.g. Fly.io 6PN). +# ``None`` → aiohttp binds one socket per address family; "0.0.0.0" was unreachable on IPv6-only hosts. DEFAULT_WEBHOOK_HOST = None DEFAULT_WEBHOOK_PORT = 8090 DEFAULT_WEBHOOK_PATH = "/whatsapp/webhook" GRAPH_API_BASE = "https://graph.facebook.com" WEBHOOK_MAX_BODY_BYTES = 3 * 1024 * 1024 -# Meta retries failed webhooks for up to 7 days, but the practical duplicate -# risk is within minutes — 5000 FIFO entries bounds memory and covers that. +# Meta retries failed webhooks for up to 7 days, but the practical duplicate risk is +# within minutes — 5000 FIFO entries bounds memory and covers that. WAMID_DEDUP_CACHE_SIZE = 5000 -# Cap for the interactive-button state dicts and the per-chat last-wamid cache. -INTERACTIVE_STATE_CACHE_SIZE = 1000 +INTERACTIVE_STATE_CACHE_SIZE = 1000 # interactive-button state dicts + per-chat last-wamid cache -# Meta's hard per-type caps for the /media endpoint; refuse locally instead of -# round-tripping to Graph just to be rejected. -# https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media +# Meta's hard per-type caps for /media (developers.facebook.com/docs/whatsapp/cloud-api/reference/media); +# refuse locally instead of round-tripping to Graph just to be rejected. _MEDIA_SIZE_LIMITS = { "image": 5 * 1024 * 1024, "video": 16 * 1024 * 1024, "audio": 16 * 1024 * 1024, "document": 100 * 1024 * 1024, "sticker": 100 * 1024, @@ -75,13 +71,11 @@ _DEFAULT_MIME = { "document": "application/octet-stream", "sticker": "image/webp", } -# ``shutil.which`` honours PATHEXT on Windows. None → MP3 voice falls back to an -# "audio file attachment" rendering in WhatsApp. +# None → MP3 voice falls back to an "audio file attachment" rendering in WhatsApp. _FFMPEG_PATH = shutil.which("ffmpeg") -# mimetypes returns RFC-correct but uncommon extensions (audio/ogg → .oga, -# audio/mp4 → .mp4, image/jpeg → .jpe). Downstream STT/vision whitelists the -# in-the-wild forms, so pin the few Meta sends. +# mimetypes returns RFC-correct but uncommon extensions (audio/ogg → .oga, image/jpeg → .jpe); +# downstream STT/vision whitelists the in-the-wild forms, so pin the few Meta sends. _WHATSAPP_MIME_EXTENSION_OVERRIDES: Dict[str, str] = { "audio/ogg": ".ogg", "audio/x-opus+ogg": ".ogg", "audio/opus": ".ogg", "audio/mp4": ".m4a", "audio/x-m4a": ".m4a", "image/jpeg": ".jpg", @@ -129,10 +123,7 @@ def _ext_for_mime(mime: str) -> Optional[str]: """Mime → on-disk extension: WhatsApp overrides → mimetypes → None (never the shared default table).""" if not mime: return None - return ext_for_mime( - mime, overrides=_WHATSAPP_MIME_EXTENSION_OVERRIDES, - use_defaults=False, use_mimetypes=True, fallback=None, - ) + return ext_for_mime(mime, overrides=_WHATSAPP_MIME_EXTENSION_OVERRIDES, use_defaults=False, use_mimetypes=True, fallback=None) def _cloud_allow_all_opted_in() -> bool: @@ -143,6 +134,15 @@ def _reply_to_from(metadata: Optional[Dict[str, Any]]) -> Optional[str]: return (metadata or {}).get("reply_to_message_id") +def _optional_module(name: str, unavailable_log: str) -> Any: + """Lazy-import a resolver module (tools.* may be absent in slim installs); None + warning if missing.""" + try: + return importlib.import_module(name) + except ImportError: + logger.warning(unavailable_log) + return None + + # Under the hermes dir so it survives restarts/reloads — same as the Baileys bridge. _INBOUND_MEDIA_CACHE = Path(get_hermes_dir("platforms/whatsapp_cloud/media", "whatsapp_cloud/media")) @@ -161,12 +161,10 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): def __init__(self, config: PlatformConfig): super().__init__(config, Platform.WHATSAPP_CLOUD) extra = config.extra or {} - self._phone_number_id: str = str(extra.get("phone_number_id", "")).strip() - self._access_token: str = str(extra.get("access_token", "")).strip() - self._app_id: str = str(extra.get("app_id", "")).strip() - self._app_secret: str = str(extra.get("app_secret", "")).strip() - self._waba_id: str = str(extra.get("waba_id", "")).strip() - self._verify_token: str = str(extra.get("verify_token", "")).strip() + self._phone_number_id, self._access_token, self._app_id, self._app_secret, self._waba_id, self._verify_token = ( + str(extra.get(key, "")).strip() + for key in ("phone_number_id", "access_token", "app_id", "app_secret", "waba_id", "verify_token") + ) # Falsy host (None/"") collapses to the dual-stack default. raw_host = extra.get("webhook_host", DEFAULT_WEBHOOK_HOST) or DEFAULT_WEBHOOK_HOST self._webhook_host: Optional[str] = str(raw_host) if raw_host else None @@ -187,21 +185,15 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # "allowlist" when one is configured (so it is enforced), else "open". default_dm_policy = "allowlist" if self._allow_from and not _cloud_allow_all_opted_in() else "open" self._dm_policy: str = str( - extra.get("dm_policy") - or _get_wsecret("WHATSAPP_CLOUD_DM_POLICY") - or _get_wsecret("WHATSAPP_DM_POLICY") - or default_dm_policy + extra.get("dm_policy") or _get_wsecret("WHATSAPP_CLOUD_DM_POLICY") + or _get_wsecret("WHATSAPP_DM_POLICY") or default_dm_policy ).strip().lower() self._group_policy: str = str( - extra.get("group_policy") - or _get_wsecret("WHATSAPP_CLOUD_GROUP_POLICY") - or _get_wsecret("WHATSAPP_GROUP_POLICY", default="open") - or "open" + extra.get("group_policy") or _get_wsecret("WHATSAPP_CLOUD_GROUP_POLICY") + or _get_wsecret("WHATSAPP_GROUP_POLICY", default="open") or "open" ).strip().lower() self._group_allow_from: set[str] = self._normalize_allow_ids(self._coerce_allow_list( - extra.get("group_allow_from") - or extra.get("groupAllowFrom") - or _get_wsecret("WHATSAPP_CLOUD_GROUP_ALLOW_FROM") + extra.get("group_allow_from") or extra.get("groupAllowFrom") or _get_wsecret("WHATSAPP_CLOUD_GROUP_ALLOW_FROM") )) self._mention_patterns = self._compile_mention_patterns() @@ -272,18 +264,15 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # ------------------------------------------------------------------ lifecycle async def connect(self, *, is_reconnect: bool = False) -> bool: - if not check_whatsapp_cloud_requirements(): - self._set_fatal_error( - "whatsapp_cloud_deps_missing", - "aiohttp and httpx are required for whatsapp_cloud — reinstall hermes-agent.", retryable=False, - ) - return False - if not self._phone_number_id or not self._access_token: - self._set_fatal_error( - "whatsapp_cloud_unconfigured", - "WHATSAPP_CLOUD_PHONE_NUMBER_ID and WHATSAPP_CLOUD_ACCESS_TOKEN are required.", retryable=False, - ) - return False + for ok, code, message in ( + (check_whatsapp_cloud_requirements(), "whatsapp_cloud_deps_missing", + "aiohttp and httpx are required for whatsapp_cloud — reinstall hermes-agent."), + (self._phone_number_id and self._access_token, "whatsapp_cloud_unconfigured", + "WHATSAPP_CLOUD_PHONE_NUMBER_ID and WHATSAPP_CLOUD_ACCESS_TOKEN are required."), + ): + if not ok: + self._set_fatal_error(code, message, retryable=False) + return False # Tighter keepalive so idle CLOSE_WAIT drains promptly. from gateway.platforms._http_client_limits import platform_httpx_limits self._http_client = httpx.AsyncClient(timeout=30.0, limits=platform_httpx_limits()) @@ -301,9 +290,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self._webhook_host, self._webhook_port, self._webhook_path, self._api_version, self._phone_number_id, ) if not self._verify_token: - logger.warning( - "[whatsapp_cloud] WHATSAPP_CLOUD_VERIFY_TOKEN is not set — the GET subscription handshake will fail until it is." - ) + logger.warning("[whatsapp_cloud] WHATSAPP_CLOUD_VERIFY_TOKEN is not set — the GET subscription handshake will fail until it is.") if not self._app_secret: logger.warning( "[whatsapp_cloud] WHATSAPP_CLOUD_APP_SECRET is not set — incoming webhook POSTs will be refused " @@ -372,13 +359,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): @staticmethod def _outbound_payload(chat_id: str, kind: str, block: Any, reply_to: Optional[str]) -> Dict[str, Any]: """Common ``/messages`` envelope; ``context`` quotes ``reply_to`` when given.""" - payload: Dict[str, Any] = { - "messaging_product": "whatsapp", - "recipient_type": "individual", - "to": chat_id, - "type": kind, - kind: block, - } + payload: Dict[str, Any] = {"messaging_product": "whatsapp", "recipient_type": "individual", "to": chat_id, "type": kind, kind: block} if reply_to: payload["context"] = {"message_id": reply_to} return payload @@ -406,8 +387,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): ) if err is not None: return SendResult(success=False, error=err) - if ids: - last_message_id = ids[0].get("id") + last_message_id = ids[0].get("id") if ids else last_message_id # Index (chat_id, wamid) → text: Meta's inbound ``context`` carries only the # quoted message's id, so this is how replies to our messages resolve text. if last_message_id: @@ -441,26 +421,19 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): logger.debug("[whatsapp_cloud] typing/read indicator returned %d (%s)", resp.status_code, code) # ------------------------------------------------------------------ interactive messages - # ``type=button``: ≤3 quick-reply buttons (id ≤256 chars, title ≤20); ``type=list``: one - # "Tap to choose" button opening ≤10 rows. Free-form (no Meta approval) but only valid - # inside the 24h window — fine, since all senders here fire in reply to a user message. - - async def _post_interactive( - self, chat_id: str, interactive_body: Dict[str, Any], reply_to: Optional[str] = None, - ) -> SendResult: - """POST an ``interactive`` message; caller supplies ``type``/``body``/``action``.""" - return await self._post_message_result( - self._outbound_payload(chat_id, "interactive", interactive_body, reply_to), - fail_log="[whatsapp_cloud] interactive send failed", - reject_log="[whatsapp_cloud] interactive rejected (status=%d): %s", - ) - + # Free-form (no Meta approval) but only valid inside the 24h window — fine, since all + # senders here fire in reply to a user message. async def _send_interactive( self, chat_id: str, interactive: Dict[str, Any], metadata: Optional[Dict[str, Any]], state: "OrderedDict[str, str]", state_id: str, session_key: str, ) -> SendResult: - """Post an interactive prompt and, on success, remember ``state_id → session_key`` for the tap.""" - result = await self._post_interactive(chat_id, interactive, reply_to=_reply_to_from(metadata)) + """POST an ``interactive`` message (caller supplies ``type``/``body``/``action``) and, on + success, remember ``state_id → session_key`` for the tap.""" + result = await self._post_message_result( + self._outbound_payload(chat_id, "interactive", interactive, _reply_to_from(metadata)), + fail_log="[whatsapp_cloud] interactive send failed", + reject_log="[whatsapp_cloud] interactive rejected (status=%d): %s", + ) if result.success: self._bounded_put(state, state_id, session_key) return result @@ -483,17 +456,16 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): @classmethod def _button_interactive(cls, body_text: str, *buttons: tuple[str, str]) -> Dict[str, Any]: + """``interactive.type=button`` body: ≤3 quick-reply buttons (id ≤256 chars, title ≤20).""" return {"type": "button", "body": {"text": body_text}, "action": {"buttons": cls._reply_buttons(*buttons)}} async def send_clarify( self, chat_id: str, question: str, choices: Optional[list], clarify_id: str, session_key: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Clarify as native buttons: 1–3 choices → buttons, 4+ → list (+ "Other" row - that flips the entry into text-capture mode), 0 → plain text question. - - Button ``id`` carries ``cl::``; inbound dispatches on it. - """ + """Clarify as native buttons: 1–3 choices → buttons, 4+ → list (+ "Other" row that flips + the entry into text-capture mode), 0 → plain text question. Button ``id`` carries + ``cl::``; inbound dispatches on it.""" if self._http_client is None: return SendResult(success=False, error="Not connected") question = (question or "").strip() @@ -517,11 +489,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): for idx, choice_text in enumerate(choices_list) ] rows.append({"id": f"cl:{clarify_id}:other", "title": "✏️ Other", "description": "Type your own answer"}) - interactive = { - "type": "list", - "body": {"text": body_text}, - "action": {"button": "Choose", "sections": [{"title": "Options", "rows": rows}]}, - } + interactive = {"type": "list", "body": {"text": body_text}, "action": {"button": "Choose", "sections": [{"title": "Options", "rows": rows}]}} return await self._send_interactive(chat_id, interactive, metadata, self._clarify_state, clarify_id, session_key) async def send_exec_approval( @@ -539,12 +507,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): + ("\n\nSmart DENY: owner override applies to this one operation only." if smart_denied else "") ) approval_id = uuid.uuid4().hex[:12] - interactive = self._button_interactive( - body_text, (f"appr:{approval_id}:approve", "✅ Approve"), (f"appr:{approval_id}:deny", "❌ Deny"), - ) - return await self._send_interactive( - chat_id, interactive, metadata, self._exec_approval_state, approval_id, session_key - ) + interactive = self._button_interactive(body_text, (f"appr:{approval_id}:approve", "✅ Approve"), (f"appr:{approval_id}:deny", "❌ Deny")) + return await self._send_interactive(chat_id, interactive, metadata, self._exec_approval_state, approval_id, session_key) async def send_slash_confirm( self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, @@ -552,14 +516,10 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): ) -> SendResult: """Approve Once / Always / Cancel buttons; ``confirm_id`` is caller-supplied.""" interactive = self._button_interactive( - self._truncate_body(f"*{title}*\n\n{message}"), - (f"sc:once:{confirm_id}", "✅ Approve Once"), - (f"sc:always:{confirm_id}", "🔒 Always"), - (f"sc:cancel:{confirm_id}", "❌ Cancel"), - ) - return await self._send_interactive( - chat_id, interactive, metadata, self._slash_confirm_state, confirm_id, session_key + self._truncate_body(f"*{title}*\n\n{message}"), (f"sc:once:{confirm_id}", "✅ Approve Once"), + (f"sc:always:{confirm_id}", "🔒 Always"), (f"sc:cancel:{confirm_id}", "❌ Cancel"), ) + return await self._send_interactive(chat_id, interactive, metadata, self._slash_confirm_state, confirm_id, session_key) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: # No chat-info endpoint; profile name arrives via webhook contacts[]. @@ -574,8 +534,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return None, "Not connected" if not os.path.exists(file_path): return None, f"File not found: {file_path}" - size = os.path.getsize(file_path) - cap = _MEDIA_SIZE_LIMITS.get(media_kind, _MEDIA_SIZE_LIMITS["document"]) + size, cap = os.path.getsize(file_path), _MEDIA_SIZE_LIMITS.get(media_kind, _MEDIA_SIZE_LIMITS["document"]) if size > cap: return None, f"File {os.path.basename(file_path)} is {size} bytes; Cloud API {media_kind} cap is {cap} bytes" mime_type = mime_type or mimetypes.guess_type(file_path)[0] or _DEFAULT_MIME.get(media_kind, "application/octet-stream") @@ -646,8 +605,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): file_name: Optional[str] = None, reply_to: Optional[str] = None, **kwargs, ) -> SendResult: return await self._send_media_from_path_or_link( - chat_id, file_path, "document", - caption=caption, filename=file_name or os.path.basename(file_path), reply_to=reply_to, + chat_id, file_path, "document", caption=caption, filename=file_name or os.path.basename(file_path), reply_to=reply_to, ) async def send_voice( @@ -662,8 +620,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if opus_path: try: return await self._send_media_from_path_or_link( - chat_id, opus_path, "audio", caption=caption, reply_to=reply_to, - mime_type="audio/ogg; codecs=opus", + chat_id, opus_path, "audio", caption=caption, reply_to=reply_to, mime_type="audio/ogg; codecs=opus", ) finally: # The .ogg is a transient artifact next to the source MP3. @@ -673,9 +630,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): pass # No ffmpeg (warn-once logged in _convert_to_opus) → MP3 attachment. mime_type = "audio/mpeg" - return await self._send_media_from_path_or_link( - chat_id, audio_path, "audio", caption=caption, reply_to=reply_to, mime_type=mime_type, - ) + return await self._send_media_from_path_or_link(chat_id, audio_path, "audio", caption=caption, reply_to=reply_to, mime_type=mime_type) async def _convert_to_opus(self, mp3_path: str) -> Optional[str]: """MP3 → ``audio/ogg; codecs=opus``; None if ffmpeg is missing or fails. ``-application voip`` @@ -692,21 +647,20 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): out_path = mp3_path.rsplit(".", 1)[0] + ".ogg" try: proc = await asyncio.create_subprocess_exec( - _FFMPEG_PATH, "-y", "-i", mp3_path, - "-c:a", "libopus", "-b:a", "32k", "-vbr", "on", "-application", "voip", out_path, - stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, + _FFMPEG_PATH, "-y", "-i", mp3_path, "-c:a", "libopus", "-b:a", "32k", "-vbr", "on", "-application", "voip", + out_path, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) _, stderr = await proc.communicate() except Exception: logger.exception("[whatsapp_cloud] ffmpeg subprocess raised") return None - if proc.returncode != 0 or not Path(out_path).exists(): - logger.error( - "[whatsapp_cloud] ffmpeg opus conversion failed (returncode=%s): %s", - proc.returncode, (stderr or b"").decode("utf-8", errors="replace")[:500], - ) - return None - return out_path + if proc.returncode == 0 and Path(out_path).exists(): + return out_path + logger.error( + "[whatsapp_cloud] ffmpeg opus conversion failed (returncode=%s): %s", + proc.returncode, (stderr or b"").decode("utf-8", errors="replace")[:500], + ) + return None # ------------------------------------------------------------------ inbound media async def _graph_get(self, url: str, headers: Dict[str, str], what: str, media_id: str) -> Any: @@ -817,10 +771,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): def _verify_signature(self, raw_body: bytes, header: str) -> bool: """Verify ``sha256=`` HMAC of the raw body keyed by ``app_secret`` (constant-time).""" - if not self._app_secret or not header or not header.startswith("sha256="): - return False - expected_hex = header[len("sha256="):].strip() - if not expected_hex: + if not (self._app_secret and header and header.startswith("sha256=") and (expected_hex := header[7:].strip())): return False computed = hmac.new(self._app_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() # Compare as bytes: compare_digest raises TypeError on non-ASCII str. @@ -893,9 +844,9 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): dispatch with the button title — covers stale taps.""" inner = _interactive_inner(raw_message) button_id = str(inner.get("id") or "").strip() + sender_id = str(raw_message.get("from") or "").strip() if not button_id: return False - sender_id = str(raw_message.get("from") or "").strip() if not self._is_interactive_sender_authorized(sender_id): logger.warning("[whatsapp_cloud] Rejected unauthorized interactive tap from %s (button_id=%r)", sender_id or "", button_id) return True # claim so the tap isn't re-dispatched as plain text @@ -928,19 +879,17 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): ) if not session_key: return False - try: - from tools.clarify_gateway import resolve_gateway_clarify - except ImportError: - logger.warning("[whatsapp_cloud] clarify resolver unavailable; falling back to text dispatch") + clarify_gateway = _optional_module( + "tools.clarify_gateway", "[whatsapp_cloud] clarify resolver unavailable; falling back to text dispatch" + ) + if clarify_gateway is None: return False if choice == "other": - # Flip the entry into text-capture mode so the gateway's text intercept - # resolves the clarify with the next message; otherwise that text would - # hit the agent path while it's still blocked in clarify ("Interrupting - # current task" loop). + # Flip the entry into text-capture mode so the gateway's text intercept resolves the + # clarify with the next message; otherwise that text would hit the agent path while + # it's still blocked in clarify ("Interrupting current task" loop). try: - from tools.clarify_gateway import mark_awaiting_text - flipped = mark_awaiting_text(clarify_id) + flipped = clarify_gateway.mark_awaiting_text(clarify_id) except Exception: logger.exception("[whatsapp_cloud] mark_awaiting_text failed for %s", clarify_id) flipped = False @@ -959,7 +908,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self._clarify_state[clarify_id] = session_key # a follow-up text can still resolve return False # Title is the numeric label; the agent has the prompt in context to interpret it. - if not resolve_gateway_clarify(clarify_id, str(inner.get("title") or str(idx + 1))): + if not clarify_gateway.resolve_gateway_clarify(clarify_id, str(inner.get("title") or str(idx + 1))): logger.info("[whatsapp_cloud] clarify resolver reported no waiter (clarify_id=%s) — falling back to text", clarify_id) return False return True @@ -975,12 +924,10 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if choice not in ("approve", "deny"): self._exec_approval_state[approval_id] = session_key return False - try: - from tools.approval import resolve_gateway_approval - except ImportError: - logger.warning("[whatsapp_cloud] approval resolver unavailable") + approval = _optional_module("tools.approval", "[whatsapp_cloud] approval resolver unavailable") + if approval is None: return False - count = resolve_gateway_approval(session_key, choice) + count = approval.resolve_gateway_approval(session_key, choice) # A tap after the wait timed out (count == 0) must not claim approval: # the command was already denied fail-closed. if count: @@ -1002,13 +949,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if choice not in ("once", "always", "cancel"): self._slash_confirm_state[confirm_id] = session_key return False - try: - from tools import slash_confirm as _slash_confirm_mod - except ImportError: - logger.warning("[whatsapp_cloud] slash_confirm resolver unavailable") + slash_confirm = _optional_module("tools.slash_confirm", "[whatsapp_cloud] slash_confirm resolver unavailable") + if slash_confirm is None: return False try: - result_text = await _slash_confirm_mod.resolve(session_key, confirm_id, choice) + result_text = await slash_confirm.resolve(session_key, confirm_id, choice) except Exception: logger.exception("[whatsapp_cloud] slash_confirm.resolve failed") return True # still claim the tap; surfacing it as text wouldn't help @@ -1016,11 +961,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): await self._reply_best_effort(to, result_text, "[whatsapp_cloud] slash_confirm reply failed") return True - _INTERACTIVE_HANDLERS = { - "cl:": _handle_clarify_tap, - "appr:": _handle_approval_tap, - "sc:": _handle_slash_confirm_tap, - } + _INTERACTIVE_HANDLERS = {"cl:": _handle_clarify_tap, "appr:": _handle_approval_tap, "sc:": _handle_slash_confirm_tap} async def _collect_inbound_media( self, msg_type_str: str, raw_message: Dict[str, Any], body: str @@ -1029,8 +970,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): media_urls: list[str] = [] media_types: list[str] = [] inner = raw_message.get(msg_type_str) or {} - media_id = str(inner.get("id") or "").strip() - inbound_mime = str(inner.get("mime_type") or "").strip() + media_id, inbound_mime = str(inner.get("id") or "").strip(), str(inner.get("mime_type") or "").strip() if not media_id: return media_urls, media_types, body local_path, dl_mime = await self._download_media_to_cache(media_id, ext_hint=_ext_for_mime(inbound_mime)) @@ -1090,8 +1030,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): chat_id = sender_id if not self._should_process_message({"chatId": chat_id, "senderId": sender_id, "isGroup": False, "body": body}): return None - media_urls: list[str] = [] - media_types: list[str] = [] + media_urls, media_types = [], [] if msg_type_str in _INBOUND_MEDIA_KINDS: media_urls, media_types, body = await self._collect_inbound_media(msg_type_str, raw_message, body) if msg_type_str == "document" and media_urls: From cf8b86b589d3f93b25020192a570e8aecb4537ca Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:51:15 -0700 Subject: [PATCH 10/10] refactor(gateway/whatsapp): fold graph-error formatter into _response_error, shared bounded_put for wamid dedup, compact signatures --- gateway/platforms/whatsapp_cloud.py | 197 ++++++++++----------------- gateway/platforms/whatsapp_common.py | 36 ++--- 2 files changed, 84 insertions(+), 149 deletions(-) diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index c4d2b3dd56..333fec7727 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -10,6 +10,7 @@ _WEBHOOK_PORT (8090), _WEBHOOK_PATH (/whatsapp/webhook), _API_VERSION (v20.0)."" from __future__ import annotations import asyncio +import contextlib import hashlib import hmac import importlib @@ -121,9 +122,7 @@ async def _read_limited_request_body(request: Any, max_bytes: int) -> bytes: def _ext_for_mime(mime: str) -> Optional[str]: """Mime → on-disk extension: WhatsApp overrides → mimetypes → None (never the shared default table).""" - if not mime: - return None - return ext_for_mime(mime, overrides=_WHATSAPP_MIME_EXTENSION_OVERRIDES, use_defaults=False, use_mimetypes=True, fallback=None) + return ext_for_mime(mime, overrides=_WHATSAPP_MIME_EXTENSION_OVERRIDES, use_defaults=False, use_mimetypes=True, fallback=None) if mime else None def _cloud_allow_all_opted_in() -> bool: @@ -166,20 +165,16 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): for key in ("phone_number_id", "access_token", "app_id", "app_secret", "waba_id", "verify_token") ) # Falsy host (None/"") collapses to the dual-stack default. - raw_host = extra.get("webhook_host", DEFAULT_WEBHOOK_HOST) or DEFAULT_WEBHOOK_HOST - self._webhook_host: Optional[str] = str(raw_host) if raw_host else None + self._webhook_host: Optional[str] = str(extra.get("webhook_host") or DEFAULT_WEBHOOK_HOST or "") or None self._webhook_port: int = int(extra.get("webhook_port", DEFAULT_WEBHOOK_PORT)) self._webhook_path: str = self._normalize_path(extra.get("webhook_path", DEFAULT_WEBHOOK_PATH)) self._health_path: str = self._normalize_path(extra.get("health_path", "/health")) self._api_version: str = str(extra.get("api_version", DEFAULT_API_VERSION)) - # Behavior-mixin contract attributes. WHATSAPP_CLOUD_* env vars take precedence so # both adapters can run in parallel with independent policies; shared WHATSAPP_* # names remain the fallback. Allowlist: config, then legacy ALLOW_FROM, then ALLOWED_USERS. self._reply_prefix: Optional[str] = extra.get("reply_prefix") - allow_raw = self._select_dm_allowlist( - extra, ("WHATSAPP_CLOUD_ALLOW_FROM", "WHATSAPP_CLOUD_ALLOWED_USERS"), _get_wsecret - ) + allow_raw = self._select_dm_allowlist(extra, ("WHATSAPP_CLOUD_ALLOW_FROM", "WHATSAPP_CLOUD_ALLOWED_USERS"), _get_wsecret) self._allow_from: set[str] = self._normalize_allow_ids(self._coerce_allow_list(allow_raw)) # DM policy default: "open" if the operator opted into allow-all, else # "allowlist" when one is configured (so it is enforced), else "open". @@ -196,7 +191,6 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): extra.get("group_allow_from") or extra.get("groupAllowFrom") or _get_wsecret("WHATSAPP_CLOUD_GROUP_ALLOW_FROM") )) self._mention_patterns = self._compile_mention_patterns() - # Webhook dedup state (in-memory, FIFO-evicted) and counters. self._seen_wamids: "OrderedDict[str, bool]" = OrderedDict() self._duplicate_count = self._accepted_count = self._rejected_signature_count = 0 @@ -210,8 +204,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self._clarify_state: "OrderedDict[str, str]" = OrderedDict() self._exec_approval_state: "OrderedDict[str, str]" = OrderedDict() self._slash_confirm_state: "OrderedDict[str, str]" = OrderedDict() - self._runner = None - self._http_client: Optional["httpx.AsyncClient"] = None + self._runner = self._http_client = None # ------------------------------------------------------------------ helpers @staticmethod @@ -221,19 +214,17 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): def _graph_url(self, path: str) -> str: """Build a Graph API URL for this adapter's phone-number scope.""" - if path.startswith("/"): - path = path[1:] - return f"{GRAPH_API_BASE}/{self._api_version}/{self._phone_number_id}/{path}" + return f"{GRAPH_API_BASE}/{self._api_version}/{self._phone_number_id}/{path.removeprefix('/')}" def _auth_headers(self, *, json_body: bool = True) -> Dict[str, str]: headers = {"Authorization": f"Bearer {self._access_token}"} return {**headers, "Content-Type": "application/json"} if json_body else headers @staticmethod - def _bounded_put(cache: "OrderedDict[str, str]", key: str, value: str) -> None: + def _bounded_put(cache: "OrderedDict[str, Any]", key: str, value: Any, cap: int = INTERACTIVE_STATE_CACHE_SIZE) -> None: """Insert into a FIFO-capped OrderedDict, evicting oldest entries.""" cache[key] = value - while len(cache) > INTERACTIVE_STATE_CACHE_SIZE: + while len(cache) > cap: cache.popitem(last=False) def _effective_reply_prefix(self) -> str: @@ -256,12 +247,6 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): """Also honor the documented WHATSAPP_CLOUD_ALLOW_ALL_USERS opt-in.""" return _cloud_allow_all_opted_in() or super()._open_dm_opted_in() - def _is_interactive_sender_authorized(self, sender_id: str) -> bool: - """Interactive taps bypass ``_should_process_message``; re-check the strict DM gate - so a stale prompt can't be answered after the sender leaves the allowlist.""" - principal = str(sender_id or "").strip() - return bool(principal) and self._is_dm_allowed(principal) - # ------------------------------------------------------------------ lifecycle async def connect(self, *, is_reconnect: bool = False) -> bool: for ok, code, message in ( @@ -312,14 +297,6 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): self._mark_disconnected() # ------------------------------------------------------------------ outbound - @staticmethod - def _format_graph_error(body: Dict[str, Any], status_code: int) -> str: - # Graph shape: {"error": {"message", "type", "code", "fbtrace_id"}} - err = (body or {}).get("error") or {} - message = err.get("message") or body.get("raw") or "unknown error" - code = err.get("code") - return f"graph error {code} (HTTP {status_code}): {message}" if code is not None else f"HTTP {status_code}: {message}" - @staticmethod def _response_error(resp: Any) -> str: """Map a non-200 Graph response to an actionable error string.""" @@ -327,11 +304,13 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): body = resp.json() except Exception: body = {"raw": resp.text[:500]} - return WhatsAppCloudAdapter._format_graph_error(body, resp.status_code) + # Graph shape: {"error": {"message", "type", "code", "fbtrace_id"}} + err = (body or {}).get("error") or {} + message = err.get("message") or body.get("raw") or "unknown error" + code = err.get("code") + return f"graph error {code} (HTTP {resp.status_code}): {message}" if code is not None else f"HTTP {resp.status_code}: {message}" - async def _post_messages( - self, payload: Dict[str, Any], *, fail_log: str, reject_log: str, reject_args: tuple = (), - ) -> tuple[list, Optional[str]]: + async def _post_messages(self, payload: Dict[str, Any], *, fail_log: str, reject_log: str, reject_args: tuple = ()) -> tuple[list, Optional[str]]: """POST one /messages payload. Returns ``(response messages[], None)`` or ``([], error)``.""" try: resp = await self._http_client.post(self._graph_url("messages"), headers=self._auth_headers(), json=payload) @@ -352,9 +331,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if self._http_client is None: return SendResult(success=False, error="Not connected") ids, err = await self._post_messages(payload, **log_kwargs) - if err is not None: - return SendResult(success=False, error=err) - return SendResult(success=True, message_id=ids[0].get("id") if ids else None) + return SendResult(success=False, error=err) if err is not None else SendResult(success=True, message_id=ids[0].get("id") if ids else None) @staticmethod def _outbound_payload(chat_id: str, kind: str, block: Any, reply_to: Optional[str]) -> Dict[str, Any]: @@ -364,10 +341,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): payload["context"] = {"message_id": reply_to} return payload - async def send( - self, chat_id: str, content: str, reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: + async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: """Send a text message via Graph API. ``chat_id`` is the recipient's ``wa_id``.""" if self._http_client is None: return SendResult(success=False, error="Not connected") @@ -413,22 +387,21 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): code = ((resp.json() or {}).get("error") or {}).get("code") except Exception: code = None - # Code 131009 = "Parameter value is not valid" (typically wamid > 30 days - # old) — common after a long-quiet conversation, so log at info. + # 131009 = "Parameter value is not valid" (typically wamid > 30 days old) — common + # after a long-quiet conversation, so log at info. if code == 131009: logger.info("[whatsapp_cloud] typing/read indicator rejected: wamid %s likely older than 30 days", wamid) else: logger.debug("[whatsapp_cloud] typing/read indicator returned %d (%s)", resp.status_code, code) # ------------------------------------------------------------------ interactive messages - # Free-form (no Meta approval) but only valid inside the 24h window — fine, since all - # senders here fire in reply to a user message. async def _send_interactive( self, chat_id: str, interactive: Dict[str, Any], metadata: Optional[Dict[str, Any]], state: "OrderedDict[str, str]", state_id: str, session_key: str, ) -> SendResult: """POST an ``interactive`` message (caller supplies ``type``/``body``/``action``) and, on - success, remember ``state_id → session_key`` for the tap.""" + success, remember ``state_id → session_key`` for the tap. Free-form interactives need no + Meta approval but are only valid inside the 24h window — fine, all senders here reply to a user.""" result = await self._post_message_result( self._outbound_payload(chat_id, "interactive", interactive, _reply_to_from(metadata)), fail_log="[whatsapp_cloud] interactive send failed", @@ -466,21 +439,17 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): """Clarify as native buttons: 1–3 choices → buttons, 4+ → list (+ "Other" row that flips the entry into text-capture mode), 0 → plain text question. Button ``id`` carries ``cl::``; inbound dispatches on it.""" - if self._http_client is None: - return SendResult(success=False, error="Not connected") question = (question or "").strip() if not choices: return await self.send(chat_id, f"❓ {question}", reply_to=_reply_to_from(metadata)) # Full choice text goes in the body so long options aren't lost to the # 20-char label cap; labels are just the option number. choices_list = [str(c).strip() for c in choices[:10] if str(c).strip()] - option_lines = "\n".join(f"{i + 1}. {c}" for i, c in enumerate(choices_list)) - body_text = self._truncate_body(f"❓ {question}\n\n{option_lines}") + body_text = self._truncate_body(f"❓ {question}\n\n" + "\n".join(f"{i + 1}. {c}" for i, c in enumerate(choices_list))) if len(choices_list) <= 3: - interactive = self._button_interactive(body_text, *( - (f"cl:{clarify_id}:{idx}", self._truncate_button_label(str(idx + 1))) - for idx in range(len(choices_list)) - )) + interactive = self._button_interactive( + body_text, *((f"cl:{clarify_id}:{idx}", self._truncate_button_label(str(idx + 1))) for idx in range(len(choices_list))) + ) else: # List rows: id + title (≤24) + description (≤72) with the choice text. rows = [ @@ -500,8 +469,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): """Approve / Deny buttons; a tap resolves via ``tools.approval.resolve_gateway_approval``.""" del allow_permanent, allow_session # This adapter already offers one-shot Approve / Deny only. # Body caps at 1024; reserve room for the framing prose. - cmd = command or "" - cmd_preview = cmd if len(cmd) <= 800 else cmd[:800] + "..." + cmd_preview = (command or "")[:800] + ("..." if len(command or "") > 800 else "") body_text = self._truncate_body( f"⚠️ *Command Approval Required*\n\n```\n{cmd_preview}\n```\n\nReason: {description}" + ("\n\nSmart DENY: owner override applies to this one operation only." if smart_denied else "") @@ -511,8 +479,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return await self._send_interactive(chat_id, interactive, metadata, self._exec_approval_state, approval_id, session_key) async def send_slash_confirm( - self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Approve Once / Always / Cancel buttons; ``confirm_id`` is caller-supplied.""" interactive = self._button_interactive( @@ -526,9 +493,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return {"name": chat_id, "type": "dm"} # ------------------------------------------------------------------ outbound media - async def _upload_media( - self, file_path: str, media_kind: str, mime_type: Optional[str] = None, - ) -> tuple[Optional[str], Optional[str]]: + async def _upload_media(self, file_path: str, media_kind: str, mime_type: Optional[str] = None) -> tuple[Optional[str], Optional[str]]: """Upload a local file to Graph /media. Returns ``(media_id, None)`` or ``(None, error)``.""" if self._http_client is None: return None, "Not connected" @@ -601,17 +566,13 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return await self._send_media_from_path_or_link(chat_id, video_path, "video", caption=caption, reply_to=reply_to) 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, **kwargs, + self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, reply_to: Optional[str] = None, **kwargs, ) -> SendResult: return await self._send_media_from_path_or_link( chat_id, file_path, "document", caption=caption, filename=file_name or os.path.basename(file_path), reply_to=reply_to, ) - async def send_voice( - self, chat_id: str, audio_path: str, caption: Optional[str] = None, - reply_to: Optional[str] = None, **kwargs, - ) -> SendResult: + async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs) -> SendResult: """Voice message: ``audio/ogg; codecs=opus`` renders as a voice bubble, so a local MP3 (Hermes TTS output) is converted via ffmpeg first; other audio is sent as-is.""" mime_type: Optional[str] = None @@ -623,11 +584,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): chat_id, opus_path, "audio", caption=caption, reply_to=reply_to, mime_type="audio/ogg; codecs=opus", ) finally: - # The .ogg is a transient artifact next to the source MP3. - try: + with contextlib.suppress(OSError): # the .ogg is a transient artifact next to the source MP3 os.unlink(opus_path) - except OSError: - pass # No ffmpeg (warn-once logged in _convert_to_opus) → MP3 attachment. mime_type = "audio/mpeg" return await self._send_media_from_path_or_link(chat_id, audio_path, "audio", caption=caption, reply_to=reply_to, mime_type=mime_type) @@ -675,9 +633,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return None return resp - async def _download_media_to_cache( - self, media_id: str, *, ext_hint: Optional[str] = None, - ) -> tuple[Optional[str], Optional[str]]: + async def _download_media_to_cache(self, media_id: str, *, ext_hint: Optional[str] = None) -> tuple[Optional[str], Optional[str]]: """Two-step Graph download: ``GET /`` → signed temp URL (~5 min) → bytes. Returns ``(local_path, mime_type)`` or ``(None, None)`` on any failure (logged).""" if self._http_client is None: @@ -695,7 +651,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): try: meta = meta_resp.json() except Exception: - return None, None + meta = {} temp_url, mime = meta.get("url"), meta.get("mime_type") or "" if not temp_url: return None, None @@ -728,15 +684,15 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if not self._verify_token: # Refuse rather than accept any token, which would let an attacker subscribe. return web.Response(status=503, text="verify_token not configured") - if request.query.get("hub.mode", "") != "subscribe": + q = request.query + if q.get("hub.mode", "") != "subscribe": return web.Response(status=400, text="bad mode") # Compare as bytes: compare_digest raises TypeError on non-ASCII str. - if not hmac.compare_digest(request.query.get("hub.verify_token", "").encode(), self._verify_token.encode()): + if not hmac.compare_digest(q.get("hub.verify_token", "").encode(), self._verify_token.encode()): return web.Response(status=403, text="verify_token mismatch") - challenge = request.query.get("hub.challenge", "") - if not challenge: + if not q.get("hub.challenge", ""): return web.Response(status=400, text="missing challenge") - return web.Response(text=challenge, content_type="text/plain") + return web.Response(text=q["hub.challenge"], content_type="text/plain") async def _handle_webhook(self, request: "web.Request") -> "web.Response": """Inbound webhook POST: raw bytes → HMAC verify → JSON → dispatch. Signature is over @@ -780,14 +736,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): # ------------------------------------------------------------------ dispatch def _dedup_wamid(self, wamid: str) -> bool: """True if this wamid is new; False (and count a duplicate) if already seen.""" - if not wamid: - return True # can't dedup without an id — let it through if wamid in self._seen_wamids: self._duplicate_count += 1 return False - self._seen_wamids[wamid] = True - while len(self._seen_wamids) > WAMID_DEDUP_CACHE_SIZE: - self._seen_wamids.popitem(last=False) + if wamid: # can't dedup without an id — let it through + self._bounded_put(self._seen_wamids, wamid, True, cap=WAMID_DEDUP_CACHE_SIZE) return True async def _dispatch_payload(self, payload: Dict[str, Any]) -> None: @@ -813,9 +766,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): if isinstance(status, dict): logger.debug("[whatsapp_cloud] status %s for %s", status.get("status"), status.get("id")) - async def _ingest_message( - self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str], metadata: Dict[str, Any], - ) -> None: + async def _ingest_message(self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str], metadata: Dict[str, Any]) -> None: """Dedup → build event → handle_message. Neither build nor dispatch errors may bubble out: the wamid is already dedup-marked, so a 500 would make Meta retry the batch and every message in it would then be dropped as a duplicate.""" @@ -836,9 +787,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): except Exception: logger.exception("[whatsapp_cloud] handle_message raised for wamid %s", wamid) - async def _dispatch_interactive_reply( - self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str], - ) -> bool: + async def _dispatch_interactive_reply(self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str]) -> bool: """Route an inbound button tap to its resolver (see ``_INTERACTIVE_HANDLERS``). True = claimed. False (unknown prefix, no live state, or no waiter) makes the caller fall back to text dispatch with the button title — covers stale taps.""" @@ -847,7 +796,9 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): sender_id = str(raw_message.get("from") or "").strip() if not button_id: return False - if not self._is_interactive_sender_authorized(sender_id): + # Taps bypass ``_should_process_message``; re-check the strict DM gate so a stale + # prompt can't be answered after the sender leaves the allowlist. + if not (sender_id and self._is_dm_allowed(sender_id)): logger.warning("[whatsapp_cloud] Rejected unauthorized interactive tap from %s (button_id=%r)", sender_id or "", button_id) return True # claim so the tap isn't re-dispatched as plain text parts = button_id.split(":", 2) @@ -864,11 +815,17 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): logger.exception(fail_log) @staticmethod - def _pop_tap_state(state: "OrderedDict[str, str]", key: str, stale_log: str) -> Optional[str]: - """Pop the session_key for a tapped prompt; log (info) when nothing is live — likely a stale tap.""" + def _pop_tap_state( + state: "OrderedDict[str, str]", key: str, stale_log: str, choice: str = "", valid: tuple = (), + ) -> Optional[str]: + """Pop the session_key for a tapped prompt. None (info-logged) when nothing is live — likely + a stale tap; an unrecognised ``choice`` keeps the prompt live and also yields None.""" session_key = state.pop(key, None) if not session_key: logger.info(stale_log, key) + elif valid and choice not in valid: + state[key] = session_key + return None return session_key async def _handle_clarify_tap(self, to: str, inner: Dict[str, Any], parts: list) -> bool: @@ -918,12 +875,10 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): session_key = self._pop_tap_state( self._exec_approval_state, approval_id, "[whatsapp_cloud] approval tap with no matching state (approval_id=%s) — likely stale; falling back to text", + choice, ("approve", "deny"), ) if not session_key: return False - if choice not in ("approve", "deny"): - self._exec_approval_state[approval_id] = session_key - return False approval = _optional_module("tools.approval", "[whatsapp_cloud] approval resolver unavailable") if approval is None: return False @@ -943,12 +898,10 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): session_key = self._pop_tap_state( self._slash_confirm_state, confirm_id, "[whatsapp_cloud] slash_confirm tap with no matching state (confirm_id=%s) — likely stale", + choice, ("once", "always", "cancel"), ) if not session_key: return False - if choice not in ("once", "always", "cancel"): - self._slash_confirm_state[confirm_id] = session_key - return False slash_confirm = _optional_module("tools.slash_confirm", "[whatsapp_cloud] slash_confirm resolver unavailable") if slash_confirm is None: return False @@ -963,20 +916,14 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): _INTERACTIVE_HANDLERS = {"cl:": _handle_clarify_tap, "appr:": _handle_approval_tap, "sc:": _handle_slash_confirm_tap} - async def _collect_inbound_media( - self, msg_type_str: str, raw_message: Dict[str, Any], body: str - ) -> tuple[list[str], list[str], str]: + async def _collect_inbound_media(self, msg_type_str: str, raw_message: Dict[str, Any], body: str) -> tuple[list[str], list[str], str]: """Download inbound media by ``media_id``; returns ``(media_urls, media_types, body)``.""" - media_urls: list[str] = [] - media_types: list[str] = [] inner = raw_message.get(msg_type_str) or {} media_id, inbound_mime = str(inner.get("id") or "").strip(), str(inner.get("mime_type") or "").strip() if not media_id: - return media_urls, media_types, body + return [], [], body local_path, dl_mime = await self._download_media_to_cache(media_id, ext_hint=_ext_for_mime(inbound_mime)) if local_path: - media_urls.append(local_path) - media_types.append(dl_mime or inbound_mime or "application/octet-stream") logger.info("[whatsapp_cloud] cached inbound %s media: %s", msg_type_str, local_path) else: logger.warning( @@ -984,29 +931,30 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): msg_type_str, media_id, ) fname = str(inner.get("filename") or "").strip() if msg_type_str == "document" else "" - return media_urls, media_types, body or (f"[Document: {fname}]" if fname else body) + body = body or (f"[Document: {fname}]" if fname else body) + if not local_path: + return [], [], body + return [local_path], [dl_mime or inbound_mime or "application/octet-stream"], body @staticmethod def _inject_document_text(media_urls: list[str], body: str) -> str: """Prepend text-readable document contents (≤100KB) to the body.""" - for doc_path in media_urls: - if Path(doc_path).suffix.lower() not in _TEXT_INJECT_EXTS: + for doc in map(Path, media_urls): + if doc.suffix.lower() not in _TEXT_INJECT_EXTS: continue try: - file_size = Path(doc_path).stat().st_size + file_size = doc.stat().st_size if file_size > _MAX_TEXT_INJECT_BYTES: - logger.info("[whatsapp_cloud] skipping text injection for %s (%d bytes > %d)", doc_path, file_size, _MAX_TEXT_INJECT_BYTES) + logger.info("[whatsapp_cloud] skipping text injection for %s (%d bytes > %d)", doc, file_size, _MAX_TEXT_INJECT_BYTES) continue - content = Path(doc_path).read_text(encoding="utf-8", errors="replace") - injection = f"[Content of {Path(doc_path).name}]:\n{content}" + injection = f"[Content of {doc.name}]:\n{doc.read_text(encoding='utf-8', errors='replace')}" body = f"{injection}\n\n{body}" if body else injection except OSError: - logger.exception("[whatsapp_cloud] failed to read document text: %s", doc_path) + logger.exception("[whatsapp_cloud] failed to read document text: %s", doc) return body async def _build_message_event_from_cloud( - self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str], - metadata: Dict[str, Any], + self, raw_message: Dict[str, Any], contacts_by_waid: Dict[str, str], metadata: Dict[str, Any], ) -> Optional[MessageEvent]: """Convert a Cloud-API message object into a MessageEvent, or None if gated out.""" msg_type_str = str(raw_message.get("type") or "text").lower() @@ -1016,7 +964,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): return None extract = _BODY_BY_KIND.get(msg_type_str) body = str(extract(raw_message) or "") if extract else "" - sender_id = str(raw_message.get("from") or "").strip() + chat_id = sender_id = str(raw_message.get("from") or "").strip() sender_name = contacts_by_waid.get(sender_id, "") # DMs only: chat_id == sender wa_id. A ``chat`` field marks a group-shaped # payload (capability-gated by Meta) — refuse rather than treat as a DM. @@ -1027,16 +975,15 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): raw_message.get("chat"), raw_message.get("id"), ) return None - chat_id = sender_id if not self._should_process_message({"chatId": chat_id, "senderId": sender_id, "isGroup": False, "body": body}): return None media_urls, media_types = [], [] if msg_type_str in _INBOUND_MEDIA_KINDS: media_urls, media_types, body = await self._collect_inbound_media(msg_type_str, raw_message, body) - if msg_type_str == "document" and media_urls: - body = self._inject_document_text(media_urls, body) - # Meta's ``context`` gives only the quoted message's id (+ author), never its - # text; resolve from rich_sent_store so run.py can build "[Replying to: ...]". + if msg_type_str == "document" and media_urls: + body = self._inject_document_text(media_urls, body) + # Meta's ``context`` gives only the quoted message's id (+ author), never its text; + # resolve from rich_sent_store so run.py can build "[Replying to: ...]". context = raw_message.get("context") or {} reply_to_id = str(context.get("id") or "").strip() or None reply_to_text = rich_sent_store.lookup(chat_id, reply_to_id) if reply_to_id else None diff --git a/gateway/platforms/whatsapp_common.py b/gateway/platforms/whatsapp_common.py index 38cc71d632..bd3c306280 100644 --- a/gateway/platforms/whatsapp_common.py +++ b/gateway/platforms/whatsapp_common.py @@ -110,10 +110,9 @@ class WhatsAppBehaviorMixin: return {str(part).strip() for part in parts if str(part).strip()} def _select_dm_allowlist(self, extra: Dict[str, Any], env_keys, read_env) -> Any: - """Pick the raw DM allowlist by key *presence*: ``allow_from``/``allowFrom`` in - config (an explicit empty list stays authoritative), then the first truthy - env carrier. Records the winning source in ``_dm_allowlist_source`` so live - DM checks keep the same precedence.""" + """Pick the raw DM allowlist by key *presence*: ``allow_from``/``allowFrom`` in config (an + explicit empty list stays authoritative), then the first truthy env carrier. Records the + winning source in ``_dm_allowlist_source`` so live DM checks keep the same precedence.""" for key in ("allow_from", "allowFrom"): if key in extra: self._dm_allowlist_source = "config" @@ -126,13 +125,10 @@ class WhatsAppBehaviorMixin: return None def _live_dm_allow_from(self) -> set[str]: - """Allowlist currently enforced for DM intake / strict DM auth. - - Env-seeded adapters re-read the same key so pairing approve/revoke takes - effect without restart; a removed key (sole-entry revoke) means empty, not - the construction snapshot. Config-seeded adapters keep the in-memory set - (pairing revoke purges it in place) — a stale env value must not broaden access. - """ + """Allowlist currently enforced for DM intake / strict DM auth. Env-seeded adapters re-read + the same key so pairing approve/revoke takes effect without restart; a removed key (sole-entry + revoke) means empty, not the construction snapshot. Config-seeded adapters keep the in-memory + set (pairing revoke purges it in place) — a stale env value must not broaden access.""" source = getattr(self, "_dm_allowlist_source", None) if isinstance(source, str) and source != "config": return self._coerce_allow_list(os.environ[source]) if source in os.environ else set() @@ -163,12 +159,9 @@ class WhatsAppBehaviorMixin: @staticmethod def _matches_whatsapp_allowlist(candidate: str, allow_from) -> bool: - """Match a WhatsApp identifier against an allowlist across phone/LID forms. - - Inbound senders arrive as ``@lid`` while allowlists hold phone numbers - (or vice versa), so resolve both sides through the bridge's lid-mapping - files via ``gateway.whatsapp_identity``. - """ + """Match a WhatsApp identifier against an allowlist across phone/LID forms. Inbound senders + arrive as ``@lid`` while allowlists hold phone numbers (or vice versa), so resolve both + sides through the bridge's lid-mapping files via ``gateway.whatsapp_identity``.""" if not allow_from: return False if candidate in allow_from: @@ -223,10 +216,7 @@ class WhatsAppBehaviorMixin: if isinstance(patterns, str): patterns = [patterns] if not isinstance(patterns, list): - logger.warning( - "[%s] whatsapp mention_patterns must be a list or string; got %s", - self.name, type(patterns).__name__, - ) + logger.warning("[%s] whatsapp mention_patterns must be a list or string; got %s", self.name, type(patterns).__name__) return [] compiled = [] for pattern in patterns: @@ -261,10 +251,8 @@ class WhatsAppBehaviorMixin: ) def _message_matches_mention_patterns(self, data: Dict[str, Any]) -> bool: - if not self._mention_patterns: - return False body = str(data.get("body") or "") - return any(pattern.search(body) for pattern in self._mention_patterns) + return any(pattern.search(body) for pattern in self._mention_patterns or ()) def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str: if not text: