Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+409
View File
@@ -0,0 +1,409 @@
# Adding a New Messaging Platform
There are two ways to add a platform to the Hermes gateway:
## Plugin Path (Recommended for Community/Third-Party)
Create a plugin directory in `~/.hermes/plugins/` (or under `plugins/platforms/`
for bundled plugins) with a `plugin.yaml` and `adapter.py`. The adapter
inherits from `BasePlatformAdapter` and registers via
`ctx.register_platform()` in the `register(ctx)` entry point. This requires
**zero changes to core Hermes code**.
The plugin system automatically handles: adapter creation, config parsing,
user authorization, cron delivery, send_message routing, system prompt hints,
status display, gateway setup, and more.
**Optional hooks cover the edges most adapters need:**
- `env_enablement_fn: () -> Optional[dict]` — seeds `PlatformConfig.extra`
(and an optional `home_channel` dict) from env vars BEFORE the adapter is
constructed. Without this, env-only setups don't surface in
`hermes gateway status` or `get_connected_platforms()` until the SDK
instantiates.
- `apply_yaml_config_fn: (yaml_cfg, platform_cfg) -> Optional[dict]`
translate this platform's `config.yaml` keys into env vars and/or seed
`PlatformConfig.extra` directly. Lets a plugin own its YAML schema
instead of growing core `gateway/config.py` boilerplate per platform.
Mutating `os.environ` is allowed (use `not os.getenv(...)` guards to
preserve env > YAML precedence); the returned dict is merged into
`PlatformConfig.extra`. Called during `load_gateway_config()` after
the generic shared-key loop and before `_apply_env_overrides()`.
- `cron_deliver_env_var: str` — name of the `*_HOME_CHANNEL` env var. When
set, `deliver=<name>` cron jobs route to this var without editing
`cron/scheduler.py`'s hardcoded sets.
- `standalone_sender_fn: async (...) -> dict`: out-of-process delivery
for cron jobs that run separately from the gateway. Without this, a
`deliver=<name>` job fires correctly but the actual send returns
`No live adapter for platform '<name>'`. Pair with `cron_deliver_env_var`
for end-to-end cron support. See the docsite for the signature.
- `plugin.yaml` `requires_env` / `optional_env` rich-dict entries —
auto-populate `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` so the setup
wizard surfaces proper descriptions, prompts, password flags, and URLs.
**Subclassing for platform-specific UX.** When a platform has a hard
time-window constraint that the base adapter can't anticipate (LINE's
60s single-use reply token, WhatsApp's 24h session window, etc.), an
adapter can override `_keep_typing` to layer a mid-flight bubble at a
threshold without expanding the kwarg surface. Always
`await super()._keep_typing(...)` so the typing heartbeat keeps running,
and tear down your side task in `finally`. See `plugins/platforms/line/`
for the full pattern (Template Buttons postback at 45s, `RequestCache`
state machine, `interrupt_session_activity` override for `/stop`
orphans) and the developer-guide page for the prose walkthrough.
**Sibling adapters that share behavior.** When a single platform has
two transport modes the user picks between — unofficial vs official
APIs, polling vs websocket, library A vs library B — the right
structure is two adapters that share a behavior mixin. WhatsApp does
this: `gateway/platforms/whatsapp.py` (Baileys bridge) and
`gateway/platforms/whatsapp_cloud.py` (Meta Cloud API) both inherit
from `WhatsAppBehaviorMixin` in `gateway/platforms/whatsapp_common.py`.
The mixin owns gating, allow-lists, mention parsing, broadcast
filters, and the WhatsApp-flavored markdown conversion — everything
that's platform-protocol-agnostic. Each adapter owns its transport.
Both register distinct `Platform.*` enum values so the gateway can run
both simultaneously against different phone numbers. The mixin must
come **first** in the bases list — `class WhatsAppAdapter(Mixin,
BasePlatformAdapter)` — so the mixin's `format_message` overrides
`BasePlatformAdapter`'s generic default.
See `plugins/platforms/irc/`, `plugins/platforms/teams/`, and
`plugins/platforms/google_chat/` for complete working examples, and
`website/docs/developer-guide/adding-platform-adapters.md` for the full
plugin guide with code examples and hook documentation.
---
## Built-in Path (Core Contributors Only)
Checklist for integrating a platform directly into the Hermes core.
Use this as a reference when building a built-in adapter — every item here
is a real integration point. Missing any of them will cause broken
functionality, missing features, or inconsistent behavior.
---
## 1. Core Adapter (`gateway/platforms/<platform>.py`)
The adapter is a subclass of `BasePlatformAdapter` from `gateway/platforms/base.py`.
### Required methods
| Method | Purpose |
|--------|---------|
| `__init__(self, config)` | Parse config, init state. Call `super().__init__(config, Platform.YOUR_PLATFORM)` |
| `connect() -> bool` | Connect to the platform, start listeners. Return True on success |
| `disconnect()` | Stop listeners, close connections, cancel tasks |
| `send(chat_id, text, ...) -> SendResult` | Send a text message |
| `send_typing(chat_id)` | Send typing indicator |
| `send_image(chat_id, image_url, caption) -> SendResult` | Send an image |
| `get_chat_info(chat_id) -> dict` | Return `{name, type, chat_id}` for a chat |
### Optional methods (have default stubs in base)
| Method | Purpose |
|--------|---------|
| `send_document(chat_id, path, caption)` | Send a file attachment |
| `send_voice(chat_id, path)` | Send a voice message |
| `send_video(chat_id, path, caption)` | Send a video |
| `send_animation(chat_id, path, caption)` | Send a GIF/animation |
| `send_image_file(chat_id, path, caption)` | Send image from local file |
### Interactive UX (recommended if your platform supports tappable buttons)
If your platform supports interactive button/menu messages, implement these for a more polished agent experience. They all degrade gracefully to plain text when not overridden:
| Method | Purpose |
|--------|---------|
| `send_clarify(chat_id, question, choices, clarify_id, session_key, ...)` | Render the `clarify` tool's multi-choice question as tappable buttons. Pair with inbound dispatch that routes button taps to `tools.clarify_gateway.resolve_gateway_clarify`. |
| `send_exec_approval(chat_id, command, session_key, description, ...)` | Render dangerous-command approval as Approve/Deny buttons. Inbound dispatch routes to `tools.approval.resolve_gateway_approval`. |
| `send_slash_confirm(chat_id, title, message, session_key, confirm_id, ...)` | Render slash-command confirmations (e.g. `/reload-mcp`) as Once/Always/Cancel buttons. Inbound dispatch routes to `tools.slash_confirm.resolve`. |
| `send_model_picker(...)` | Interactive `/model` picker. Used by Telegram and Discord. |
| `send_choice_picker(...)` | Flat single-level picker for finite-choice commands (`/reasoning`, `/fast`). Implemented by Telegram (inline keyboard), Discord (select menu), and Matrix (reactions). Platforms without it fall back to the text status card automatically. |
See `gateway/platforms/telegram.py`, `discord.py`, and `whatsapp_cloud.py` for reference implementations. The button-callback id convention (`cl:<id>:<idx>`, `appr:<id>:<choice>`, `sc:<choice>:<id>`) is shared across adapters — match it so the gateway-side resolvers work without modification.
### Required function
```python
def check_<platform>_requirements() -> bool:
"""Check if this platform's dependencies are available."""
```
### Key patterns to follow
- Use `self.build_source(...)` to construct `SessionSource` objects
- Call `self.handle_message(event)` to dispatch inbound messages to the gateway
- Use `MessageEvent`, `MessageType`, `SendResult` from base
- Use `cache_image_from_bytes`, `cache_audio_from_bytes`, `cache_document_from_bytes` for attachments
- Filter self-messages (prevent reply loops)
- Filter sync/echo messages if the platform has them
- Redact sensitive identifiers (phone numbers, tokens) in all log output
- Implement reconnection with exponential backoff + jitter for streaming connections
- Set `MAX_MESSAGE_LENGTH` if the platform has message size limits
---
## 2. Platform Enum (`gateway/config.py`)
Add the platform to the `Platform` enum:
```python
class Platform(Enum):
...
YOUR_PLATFORM = "your_platform"
```
Add env var loading in `_apply_env_overrides()`:
```python
# Your Platform
your_token = os.getenv("YOUR_PLATFORM_TOKEN")
if your_token:
if Platform.YOUR_PLATFORM not in config.platforms:
config.platforms[Platform.YOUR_PLATFORM] = PlatformConfig()
config.platforms[Platform.YOUR_PLATFORM].enabled = True
config.platforms[Platform.YOUR_PLATFORM].token = your_token
```
Update `get_connected_platforms()` if your platform doesn't use token/api_key
(e.g., WhatsApp uses `enabled` flag, Signal uses `extra` dict).
---
## 3. Adapter Factory (`gateway/run.py`)
Add to `_instantiate_adapter()`:
```python
elif platform == Platform.YOUR_PLATFORM:
from gateway.platforms.your_platform import YourAdapter, check_your_requirements
if not check_your_requirements():
logger.warning("Your Platform: dependencies not met")
return None
return YourAdapter(config)
```
`_create_adapter()` wraps this factory and binds every successful adapter to
its `GatewayRunner`. Do not construct platform adapters in lifecycle call sites;
startup and reconnect must keep using the wrapper so profile routing is wired
before `connect()`.
---
## 4. Authorization Maps (`gateway/run.py`)
Add to BOTH dicts in `_is_user_authorized()`:
```python
platform_env_map = {
...
Platform.YOUR_PLATFORM: "YOUR_PLATFORM_ALLOWED_USERS",
}
platform_allow_all_map = {
...
Platform.YOUR_PLATFORM: "YOUR_PLATFORM_ALLOW_ALL_USERS",
}
```
---
## 5. Session Source (`gateway/session.py`)
If your platform needs extra identity fields (e.g., Signal's UUID alongside
phone number), add them to the `SessionSource` dataclass with `Optional` defaults,
and update `to_dict()`, `from_dict()`, and `build_source()` in base.py.
---
## 6. System Prompt Hints (`agent/prompt_builder.py`)
Add a `PLATFORM_HINTS` entry so the agent knows what platform it's on:
```python
PLATFORM_HINTS = {
...
"your_platform": (
"You are on Your Platform. "
"Describe formatting capabilities, media support, etc."
),
}
```
Without this, the agent won't know it's on your platform and may use
inappropriate formatting (e.g., markdown on platforms that don't render it).
---
## 7. Toolset (`toolsets.py`)
Add a named toolset for your platform:
```python
"hermes-your-platform": {
"description": "Your Platform bot toolset",
"tools": _HERMES_CORE_TOOLS,
"includes": []
},
```
And add it to the `hermes-gateway` composite:
```python
"hermes-gateway": {
"includes": [..., "hermes-your-platform"]
}
```
---
## 8. Cron Delivery (`cron/scheduler.py`)
Add to `platform_map` in `_deliver_result()`:
```python
platform_map = {
...
"your_platform": Platform.YOUR_PLATFORM,
}
```
Without this, `cronjob(action="create", deliver="your_platform", ...)` silently fails.
---
## 9. Send Message Tool (`tools/send_message_tool.py`)
Add to `platform_map` in `send_message_tool()`:
```python
platform_map = {
...
"your_platform": Platform.YOUR_PLATFORM,
}
```
Add routing in `_send_to_platform()`:
```python
elif platform == Platform.YOUR_PLATFORM:
return await _send_your_platform(pconfig, chat_id, message)
```
Implement `_send_your_platform()` — a standalone async function that sends
a single message without requiring the full adapter (for use by cron jobs
and the send_message tool outside the gateway process).
Update the tool schema `target` description to include your platform example.
---
## 10. Cronjob Tool Schema (`tools/cronjob_tools.py`)
Update the `deliver` parameter description and docstring to mention your
platform as a delivery option.
---
## 11. Channel Directory (`gateway/channel_directory.py`)
If your platform can't enumerate chats (most can't), add it to the
session-based discovery list:
```python
for plat_name in ("telegram", "whatsapp", "signal", "your_platform"):
```
---
## 12. Status Display (`hermes_cli/status.py`)
Add to the `platforms` dict in the Messaging Platforms section:
```python
platforms = {
...
"Your Platform": ("YOUR_PLATFORM_TOKEN", "YOUR_PLATFORM_HOME_CHANNEL"),
}
```
---
## 13. Gateway Setup Wizard (`hermes_cli/gateway.py`)
Add to the `_PLATFORMS` list:
```python
{
"key": "your_platform",
"label": "Your Platform",
"emoji": "📱",
"token_var": "YOUR_PLATFORM_TOKEN",
"setup_instructions": [...],
"vars": [...],
}
```
If your platform needs custom setup logic (connectivity testing, QR codes,
policy choices), add a `_setup_your_platform()` function and route to it
in the platform selection switch.
Update `_platform_status()` if your platform's "configured" check differs
from the standard `bool(get_env_value(token_var))`.
---
## 14. Phone/ID Redaction (`agent/redact.py`)
If your platform uses sensitive identifiers (phone numbers, etc.), add a
regex pattern and redaction function to `agent/redact.py`. This ensures
identifiers are masked in ALL log output, not just your adapter's logs.
---
## 15. Documentation
| File | What to update |
|------|---------------|
| `README.md` | Platform list in feature table + documentation table |
| `AGENTS.md` | Gateway description + env var config section |
| `website/docs/user-guide/messaging/<platform>.md` | **NEW** — Full setup guide (see existing platform docs for template) |
| `website/docs/user-guide/messaging/index.md` | Architecture diagram, toolset table, security examples, Next Steps links |
| `website/docs/reference/environment-variables.md` | All env vars for the platform |
---
## 16. Tests (`tests/gateway/test_<platform>.py`)
Recommended test coverage:
- Platform enum exists with correct value
- Config loading from env vars via `_apply_env_overrides`
- Adapter init (config parsing, allowlist handling, default values)
- Helper functions (redaction, parsing, file type detection)
- Session source round-trip (to_dict → from_dict)
- Authorization integration (platform in allowlist maps)
- Send message tool routing (platform in platform_map)
Optional but valuable:
- Async tests for message handling flow (mock the platform API)
- SSE/WebSocket reconnection logic
- Attachment processing
- Group message filtering
---
## Quick Verification
After implementing everything, verify with:
```bash
# All tests pass
python -m pytest tests/ -q
# Grep for your platform name to find any missed integration points
grep -r "telegram\|discord\|whatsapp\|slack" gateway/ tools/ agent/ cron/ hermes_cli/ toolsets.py \
--include="*.py" -l | sort -u
# Check each file in the output — if it mentions other platforms but not yours, you missed it
```
+45
View File
@@ -0,0 +1,45 @@
"""
Platform adapters for messaging integrations.
Each adapter handles:
- Receiving messages from a platform
- Sending messages/responses back
- Platform-specific authentication
- Message formatting and media handling
"""
from .base import BasePlatformAdapter, MessageEvent, SendResult
# QQAdapter and YuanbaoAdapter were previously imported eagerly here, but
# nothing in the codebase consumes ``from gateway.platforms import
# QQAdapter`` (every real call site uses the long-form path
# ``from gateway.platforms.qqbot import QQAdapter``). The eager imports
# pulled in qqbot's chunked-upload + keyboards + onboard machinery and
# yuanbao's websocket stack — about 48 ms wall and ~8 MB RSS on every
# CLI invocation, even ones that never touch a gateway adapter.
#
# Use PEP 562 module ``__getattr__`` to keep the public re-export working
# while deferring the actual import to first attribute access. This is
# 100% backward-compatible for any external code that still imports the
# adapters from the package root.
__all__ = [
"BasePlatformAdapter",
"MessageEvent",
"SendResult",
"QQAdapter",
"YuanbaoAdapter",
]
def __getattr__(name):
if name == "QQAdapter":
from .qqbot import QQAdapter # noqa: F401
return QQAdapter
if name == "YuanbaoAdapter":
from .yuanbao import YuanbaoAdapter # noqa: F401
return YuanbaoAdapter
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __dir__():
return sorted(__all__)
+84
View File
@@ -0,0 +1,84 @@
"""Shared HTTP client factory for long-lived platform adapters.
Gateway messaging platforms (QQ Bot, Feishu, WeCom, DingTalk, Signal,
BlueBubbles, WeCom-callback) keep a persistent ``httpx.AsyncClient``
alive for the adapter's lifetime. That amortises TLS/connection setup
across many API calls, but it also means the process's file-descriptor
pressure is sensitive to how aggressively the pool recycles idle keep-
alive connections.
httpx's default ``keepalive_expiry`` is 5 seconds. On macOS behind
Cloudflare Warp (and other transparent proxies), peer-initiated FIN can
sit in ``CLOSE_WAIT`` longer than that before the local socket actually
drains — which, multiplied across 7 long-lived adapters plus the LLM
client and MCP clients, walks straight into the default 256 fd limit.
See #18451.
``platform_httpx_limits()`` returns a tighter ``httpx.Limits`` the
adapter factories use instead of the httpx default. The values chosen:
* ``max_keepalive_connections=10`` — plenty for any single adapter;
platform APIs rarely parallelise beyond this.
* ``keepalive_expiry=2.0`` — close idle sockets aggressively so a
proxy's lingering CLOSE_WAIT window can't starve the process.
Override via ``HERMES_GATEWAY_HTTPX_KEEPALIVE_EXPIRY`` /
``HERMES_GATEWAY_HTTPX_MAX_KEEPALIVE`` env vars when tuning under load.
"""
from __future__ import annotations
import os
try:
import httpx
except ImportError: # pragma: no cover — optional dep
httpx = None # type: ignore[assignment]
_DEFAULT_KEEPALIVE_EXPIRY_S = 2.0
_DEFAULT_MAX_KEEPALIVE = 10
def platform_httpx_limits() -> "httpx.Limits | None":
"""Return ``httpx.Limits`` tuned for persistent platform-adapter clients.
Returns ``None`` when httpx isn't importable, so callers can fall
back to httpx's built-in default without a hard dependency on this
helper being reachable.
"""
if httpx is None:
return None
def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
val = float(raw)
except (TypeError, ValueError):
return default
return val if val > 0 else default
def _env_int(name: str, default: int) -> int:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
val = int(raw)
except (TypeError, ValueError):
return default
return val if val > 0 else default
keepalive_expiry = _env_float(
"HERMES_GATEWAY_HTTPX_KEEPALIVE_EXPIRY", _DEFAULT_KEEPALIVE_EXPIRY_S
)
max_keepalive = _env_int(
"HERMES_GATEWAY_HTTPX_MAX_KEEPALIVE", _DEFAULT_MAX_KEEPALIVE
)
return httpx.Limits(
max_keepalive_connections=max_keepalive,
# Leave max_connections at httpx default (100) — plenty of headroom.
keepalive_expiry=keepalive_expiry,
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
"""RoomLink dispatch validation and hidden member-session ownership."""
import asyncio
import hashlib
import hmac
import time
from typing import Any
try:
from aiohttp import web
except ImportError:
web = None # type: ignore[assignment]
async def _ensure_hosted_member_session(self, dispatch: Any) -> str:
"""Create or verify the target's canonical hidden group session.
Reusing the ``Group: <room_id>`` namespace is intentional: a room that
moves from Desktop-assisted to hosted execution keeps one transcript.
A conflicting title with a different session id fails closed instead
of merging unrelated conversations.
"""
db = await self._ensure_session_db_async()
if db is None:
raise RuntimeError("session database unavailable")
title = f"Group: {dispatch.room_id}"
seed = (
f"{dispatch.home_install_id}\0{dispatch.room_id}\0"
f"{dispatch.member_id}\0{dispatch.target_profile}"
)
session_id = f"room_{hashlib.sha256(seed.encode()).hexdigest()[:32]}"
def ensure() -> str:
def atomic(conn):
row = conn.execute(
"SELECT id, title, source FROM sessions WHERE id=?",
(session_id,),
).fetchone()
if row is not None:
if row["title"] != title or row["source"] != "bot_room":
raise RuntimeError("room session identity conflicts with existing data")
return session_id
clean_title = db.sanitize_title(title)
conflict = conn.execute(
"SELECT id FROM sessions WHERE title=? AND id!=?",
(clean_title, session_id),
).fetchone()
if conflict:
raise RuntimeError(
"Another group already uses this room title on the target gateway. "
"Rename or migrate that group before retrying."
)
conn.execute(
"INSERT INTO sessions(id, source, title, hidden, started_at) "
"VALUES(?, 'bot_room', ?, 1, ?)",
(session_id, clean_title, time.time()),
)
return session_id
return db._execute_write(atomic)
return await asyncio.to_thread(ensure)
async def _normalize_room_dispatch(
self,
request: "web.Request",
body: Any,
*,
_api_server,
) -> tuple[Any, "web.Response | None"]:
"""Validate and normalize a scoped RoomLink dispatch request."""
_api_request_profile = _api_server._api_request_profile
_openai_error = _api_server._openai_error
room_token = self._room_grant_token(request)
if not room_token:
return body, None
allowed_room_fields = {"input", "hosted_room_dispatch"}
if not isinstance(body, dict) or set(body) - allowed_room_fields:
return body, web.json_response(
_openai_error(
"Room dispatch accepts only input and hosted_room_dispatch.",
code="invalid_room_dispatch",
),
status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import (
GatewayRoomCatalog,
HostedMemberDispatch,
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
catalog_mapping,
verify_room_grant,
)
from gateway.hosted_room_execution_policy import (
RoomExecutionPolicy,
execution_policy_mapping,
)
dispatch = HostedMemberDispatch.from_mapping(
body.get("hosted_room_dispatch")
)
verify_room_grant(
self._room_grant_secret(),
room_token,
dispatch,
permission="dispatch",
)
active_profile = _api_request_profile.get() or "default"
local_install = hosted_rooms.local_authority_gateway_id()
if (
dispatch.target_profile != active_profile
or dispatch.target_install_id != local_install
):
raise ValueError("room dispatch target does not match this profile")
with self._profile_scope(active_profile):
execution_policy = execution_policy_mapping(
target_profile=active_profile
)
catalog = GatewayRoomCatalog.from_mapping(
catalog_mapping(
installation_id=local_install,
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
link_modes=("direct",),
persistent_process=True,
text=True,
attachments=False,
target_profile=active_profile,
execution_policy=execution_policy,
)
)
policy = RoomExecutionPolicy.from_mapping(
catalog.execution_policy.as_mapping()
)
if not hmac.compare_digest(
policy.policy_digest,
dispatch.execution_policy_digest,
):
raise ValueError("room execution policy changed")
if not hmac.compare_digest(
catalog.catalog_digest,
dispatch.capability_digest,
):
raise ValueError("room capability catalog changed")
supplied_input = body.get("input")
if supplied_input not in {None, dispatch.prompt}:
raise ValueError("room dispatch input does not match its prompt")
expected_key = f"room:{dispatch.task_id}:{dispatch.execution_generation}"
if request.headers.get("Idempotency-Key", "").strip() != expected_key:
raise ValueError("room dispatch idempotency key is invalid")
session_id = await self._ensure_hosted_member_session(dispatch)
return {
"input": dispatch.prompt,
"session_id": session_id,
"hosted_room_dispatch": dispatch.as_mapping(),
"_room_execution_policy": policy.as_mapping(),
}, None
except Exception as exc:
message = str(exc)
lowered = message.lower()
policy_changed = (
"execution policy" in lowered
or "remote room execution requires" in lowered
)
return body, web.json_response(
_openai_error(
(
"Room execution policy changed; reauthorization is required."
if policy_changed
else "Room capability catalog changed; reauthorization is required."
if "capability catalog changed" in lowered
else message
),
code=(
"room_execution_policy_changed"
if policy_changed
else "room_capability_catalog_changed"
if "capability catalog changed" in lowered
else "invalid_room_dispatch"
),
),
status=403,
)
+423
View File
@@ -0,0 +1,423 @@
"""RoomLink room-member grants and capability HTTP handlers."""
import time
import uuid
from typing import Any
try:
from aiohttp import web
except ImportError:
web = None # type: ignore[assignment]
class RoomGrantReauthorizationRequired(ValueError):
"""A validly signed room grant was revoked or superseded."""
def _require_unchanged_execution_policy(
claims: dict[str, Any],
execution_policy: dict[str, Any],
) -> None:
"""Keep renewal from silently granting a changed execution policy."""
if (
str(execution_policy.get("policy_digest") or "")
!= str(claims.get("execution_policy_digest") or "")
):
raise RoomGrantReauthorizationRequired(
"room execution policy changed"
)
def _room_grant_error_response(exc: Exception, *, _openai_error) -> "web.Response":
reauthorization = isinstance(exc, RoomGrantReauthorizationRequired)
return web.json_response(
_openai_error(
(
"Room authorization needs to be renewed."
if reauthorization
else "Room authorization is invalid or expired."
),
err_type="gateway_auth_error",
code=(
"room_reauthorization_required"
if reauthorization
else "invalid_room_grant"
),
),
status=403 if reauthorization else 401,
)
def _http_routes(self) -> list[tuple[str, str, Any]]:
return [
(
"POST",
"/v1/room-members/invitations",
self._handle_room_member_invitation,
),
(
"GET",
"/v1/room-members/capabilities",
self._handle_room_member_capabilities,
),
(
"POST",
"/v1/room-members/grants/refresh",
self._handle_room_member_grant_refresh,
),
(
"POST",
"/v1/room-members/grants/revoke",
self._handle_room_member_grant_revoke,
),
]
def _room_grant_token(request: "web.Request") -> str:
authorization = str(request.headers.get("Authorization") or "")
scheme, separator, token = authorization.partition(" ")
if not separator or scheme.lower() != "hermesroom":
return ""
return token.strip()
def _room_grant_secret(self) -> bytes:
from gateway.hosted_room_peer import gateway_room_grant_secret
return gateway_room_grant_secret()
def _room_grant_claims(
self,
request: "web.Request",
*,
permission: str,
) -> dict[str, Any]:
from gateway.hosted_room_peer import decode_room_grant
token = self._room_grant_token(request)
if not token:
raise ValueError("room grant is missing")
claims = decode_room_grant(
self._room_grant_secret(),
token,
permission=permission,
)
from gateway import hosted_rooms
if hosted_rooms.room_grant_is_revoked(
hosted_rooms.default_db_path(),
claims=claims,
):
raise RoomGrantReauthorizationRequired("room grant is revoked")
if not hosted_rooms.peer_room_grant_is_current(
hosted_rooms.default_db_path(),
claims=claims,
):
raise RoomGrantReauthorizationRequired("room grant is no longer current")
return claims
async def _handle_room_member_invitation(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Mint a short-lived room/profile grant for a trusted home gateway."""
auth_err = self._check_auth(request)
if auth_err:
return auth_err
body, error = await self._read_json_body(request)
if error:
return error
required = {
"room_id",
"home_install_id",
"authority_gateway_id",
"authority_epoch",
"member_id",
}
allowed = required | {"grant_id", "ttl_seconds", "status_ttl_seconds"}
if set(body) - allowed or not required <= set(body):
return web.json_response(
_openai_error(
"Invitation is missing required room authority fields.",
code="invalid_room_invitation",
),
status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import (
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
catalog_mapping,
decode_room_grant,
issue_room_grant,
)
from gateway.hosted_room_execution_policy import execution_policy_mapping
profile = _api_request_profile.get() or "default"
target_install_id = hosted_rooms.local_authority_gateway_id()
ttl = float(body.get("ttl_seconds", 3600))
if not 60 <= ttl <= 24 * 60 * 60:
raise ValueError("ttl_seconds must be between 60 and 86400")
status_ttl = float(body.get("status_ttl_seconds", ttl))
if not ttl <= status_ttl <= 30 * 24 * 60 * 60:
raise ValueError(
"status_ttl_seconds must be at least ttl_seconds and no more than 2592000"
)
with self._profile_scope(profile):
execution_policy = execution_policy_mapping(target_profile=profile)
catalog = catalog_mapping(
installation_id=target_install_id,
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
link_modes=("direct",),
persistent_process=True,
text=True,
attachments=False,
target_profile=profile,
execution_policy=execution_policy,
)
token = issue_room_grant(
self._room_grant_secret(),
grant_id=str(body.get("grant_id") or f"grant-{uuid.uuid4().hex}"),
room_id=str(body["room_id"]),
home_install_id=str(body["home_install_id"]),
authority_gateway_id=str(body["authority_gateway_id"]),
authority_epoch=int(body["authority_epoch"]),
member_id=str(body["member_id"]),
target_install_id=target_install_id,
target_profile=profile,
execution_policy_digest=execution_policy["policy_digest"],
issued_at=time.time(),
ttl_seconds=ttl,
status_ttl_seconds=status_ttl,
)
claims = decode_room_grant(
self._room_grant_secret(), token, permission="status"
)
hosted_rooms.reserve_peer_room(
hosted_rooms.default_db_path(),
claims=claims,
expires_at=float(claims.get("status_expires_at", claims["expires_at"])),
)
except Exception as exc:
return web.json_response(
_openai_error(str(exc), code="invalid_room_invitation"),
status=400,
)
return web.json_response(
{
"object": "hermes.room_member.invitation",
"grant": token,
"target_profile": profile,
"catalog": catalog,
"expires_at": float(claims["expires_at"]),
"status_expires_at": float(claims["status_expires_at"]),
},
status=201,
)
async def _handle_room_member_capabilities(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Verify a scoped grant and return this target's live room catalog."""
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import (
PROTOCOL_VERSION as ROOM_LINK_PROTOCOL_VERSION,
catalog_mapping,
)
from gateway.hosted_room_execution_policy import execution_policy_mapping
claims = self._room_grant_claims(request, permission="status")
profile = _api_request_profile.get() or "default"
installation_id = hosted_rooms.local_authority_gateway_id()
if (
claims["target_profile"] != profile
or claims["target_install_id"] != installation_id
):
raise ValueError("room grant target does not match this profile")
with self._profile_scope(profile):
execution_policy = execution_policy_mapping(target_profile=profile)
catalog = catalog_mapping(
installation_id=installation_id,
protocol_versions=(ROOM_LINK_PROTOCOL_VERSION,),
link_modes=("direct",),
persistent_process=True,
text=True,
attachments=False,
target_profile=profile,
execution_policy=execution_policy,
)
except Exception as exc:
return _room_grant_error_response(exc, _openai_error=_openai_error)
return web.json_response(
{
"object": "hermes.room_member.capabilities",
"room_id": claims["room_id"],
"home_install_id": claims["home_install_id"],
"authority_gateway_id": claims["authority_gateway_id"],
"authority_epoch": claims["authority_epoch"],
"member_id": claims["member_id"],
"target_profile": profile,
"catalog": catalog,
}
)
async def _handle_room_member_grant_refresh(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Refresh dispatch access without a Desktop or broad gateway key."""
body, error = await self._read_json_body(request)
if error:
return error
if set(body) - {"ttl_seconds"}:
return web.json_response(
_openai_error(
"Grant refresh accepts only ttl_seconds.",
code="invalid_room_grant_refresh",
),
status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import (
MAX_DISPATCH_GRANT_TTL_SECONDS,
issue_room_grant,
)
from gateway.hosted_room_execution_policy import execution_policy_mapping
# A status-only bearer may observe a run but must never mint new
# dispatch authority. Renewal is possible only while the existing
# dispatch permission is still live.
claims = self._room_grant_claims(request, permission="dispatch")
profile = _api_request_profile.get() or "default"
installation_id = hosted_rooms.local_authority_gateway_id()
if (
claims["target_profile"] != profile
or claims["target_install_id"] != installation_id
):
raise ValueError("room grant target does not match this profile")
now = time.time()
hard_expiry = float(
claims.get("status_expires_at", claims["expires_at"])
)
remaining = hard_expiry - now
requested = float(
body.get("ttl_seconds", MAX_DISPATCH_GRANT_TTL_SECONDS)
)
if remaining <= 0 or requested <= 0:
raise ValueError("room grant renewal horizon expired")
dispatch_ttl = min(
requested,
MAX_DISPATCH_GRANT_TTL_SECONDS,
remaining,
)
with self._profile_scope(profile):
execution_policy = execution_policy_mapping(target_profile=profile)
_require_unchanged_execution_policy(claims, execution_policy)
token = issue_room_grant(
self._room_grant_secret(),
grant_id=f"grant-refresh-{uuid.uuid4().hex}",
room_id=claims["room_id"],
home_install_id=claims["home_install_id"],
authority_gateway_id=claims["authority_gateway_id"],
authority_epoch=int(claims["authority_epoch"]),
member_id=claims["member_id"],
target_install_id=installation_id,
target_profile=profile,
execution_policy_digest=execution_policy["policy_digest"],
permissions=claims["permissions"],
issued_at=now,
ttl_seconds=dispatch_ttl,
status_expires_at=hard_expiry,
)
except Exception as exc:
return _room_grant_error_response(exc, _openai_error=_openai_error)
return web.json_response(
{
"object": "hermes.room_member.grant",
"grant": token,
"expires_at": now + dispatch_ttl,
"status_expires_at": hard_expiry,
"execution_policy": execution_policy,
}
)
async def _handle_room_member_grant_revoke(
self,
request: "web.Request",
*,
_openai_error,
_api_request_profile,
) -> "web.Response":
"""Revoke exactly the scoped grant authenticating this request."""
body, error = await self._read_json_body(request)
if error:
return error
if body:
return web.json_response(
_openai_error(
"Grant revoke accepts no fields.",
code="invalid_room_grant_revoke",
),
status=400,
)
try:
from gateway import hosted_rooms
from gateway.hosted_room_peer import decode_room_grant
token = self._room_grant_token(request)
if not token:
raise ValueError("room grant is missing")
# Revoke is idempotent: a response-lost retry may authenticate with
# the grant that was just added to the denylist. Verify signature,
# scope, and hard horizon directly, then upsert the same grant id.
claims = decode_room_grant(
self._room_grant_secret(),
token,
permission="status",
)
profile = _api_request_profile.get() or "default"
installation_id = hosted_rooms.local_authority_gateway_id()
if (
claims["target_profile"] != profile
or claims["target_install_id"] != installation_id
):
raise ValueError("room grant target does not match this profile")
hosted_rooms.revoke_room_grant_scope(
hosted_rooms.default_db_path(),
claims=claims,
expires_at=float(
claims.get("status_expires_at", claims["expires_at"])
),
)
except Exception:
return web.json_response(
_openai_error(
"Room authorization is invalid or expired.",
err_type="gateway_auth_error",
code="invalid_room_grant",
),
status=401,
)
return web.json_response(
{
"object": "hermes.room_member.grant.revocation",
"revoked": True,
}
)
@@ -0,0 +1,376 @@
"""Durable idempotency reservations for API server runs."""
import hmac
import json
import logging
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict
# Keep the extracted store's log records on the API server logger.
logger = logging.getLogger("gateway.platforms.api_server")
class RunIdempotencyStore:
"""Durable, tenant-scoped reservations for ``POST /v1/runs``.
A unique ``(scope, key)`` row is inserted inside ``BEGIN IMMEDIATE`` so
separate gateway workers/processes cannot both admit the same request.
Only request fingerprints and public run status are stored; request bodies
and credentials are deliberately excluded.
"""
RETENTION_SECONDS = 24 * 60 * 60
ACKNOWLEDGED_RETENTION_SECONDS = 24 * 60 * 60
@property
def durable(self) -> bool:
"""Whether reservations survive this process."""
return self._db_path is not None
def __init__(self, db_path: str = None):
if db_path is None:
try:
from hermes_cli.config import get_hermes_home
db_path = str(get_hermes_home() / "runs_idempotency.db")
except Exception:
db_path = ":memory:"
self._db_path = None if db_path == ":memory:" else db_path
try:
self._conn = sqlite3.connect(db_path, check_same_thread=False, timeout=30)
except Exception as exc:
logger.warning(
"Run idempotency storage is unavailable; falling back to "
"process memory, so replay will not survive a restart: %s",
exc,
)
self._conn = sqlite3.connect(":memory:", check_same_thread=False)
self._db_path = None
from hermes_state import apply_wal_with_fallback
apply_wal_with_fallback(self._conn, db_label="runs_idempotency.db")
self._conn.execute(
"""CREATE TABLE IF NOT EXISTS run_idempotency (
scope TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
status_json TEXT NOT NULL,
owner_pid INTEGER NOT NULL DEFAULT 0,
owner_started INTEGER NOT NULL DEFAULT 0,
retention_until REAL NOT NULL DEFAULT 0,
acknowledged_at REAL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY (scope, idempotency_key)
)"""
)
columns = {
str(row[1])
for row in self._conn.execute("PRAGMA table_info(run_idempotency)")
}
if "owner_pid" not in columns:
self._conn.execute(
"ALTER TABLE run_idempotency ADD COLUMN owner_pid INTEGER NOT NULL DEFAULT 0"
)
if "owner_started" not in columns:
self._conn.execute(
"ALTER TABLE run_idempotency ADD COLUMN owner_started INTEGER NOT NULL DEFAULT 0"
)
if "retention_until" not in columns:
self._conn.execute(
"ALTER TABLE run_idempotency ADD COLUMN "
"retention_until REAL NOT NULL DEFAULT 0"
)
if "acknowledged_at" not in columns:
self._conn.execute(
"ALTER TABLE run_idempotency ADD COLUMN acknowledged_at REAL"
)
self._conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS run_idempotency_run_id ON run_idempotency(run_id)"
)
self._conn.commit()
self._lock = threading.Lock()
self._tighten_permissions()
def _tighten_permissions(self) -> None:
if not self._db_path:
return
for candidate in (
Path(self._db_path),
Path(self._db_path + "-wal"),
Path(self._db_path + "-shm"),
):
try:
if candidate.exists():
candidate.chmod(0o600)
except OSError:
logger.debug(
"Failed to restrict run idempotency store permissions",
exc_info=True,
)
def reserve(
self,
scope: str,
key: str,
fingerprint: str,
run_id: str,
status: Dict[str, Any],
*,
owner_pid: int = 0,
owner_started: int = 0,
retention_until: float = 0,
):
"""Atomically reserve a key; return ``(outcome, stored_record)``."""
now = time.time()
retention_until = max(0.0, float(retention_until or 0))
encoded = json.dumps(status, sort_keys=True, separators=(",", ":"))
with self._lock:
self._conn.execute("BEGIN IMMEDIATE")
try:
self._prune_stale_terminal_locked(now)
row = self._conn.execute(
"SELECT fingerprint, run_id, status_json, owner_pid, owner_started, updated_at "
"FROM run_idempotency WHERE scope=? AND idempotency_key=?",
(scope, key),
).fetchone()
if row is not None:
if retention_until:
self._conn.execute(
"""UPDATE run_idempotency
SET retention_until=MAX(retention_until, ?)
WHERE scope=? AND idempotency_key=?
AND fingerprint=?""",
(retention_until, scope, key, fingerprint),
)
self._conn.commit()
outcome = (
"reused"
if hmac.compare_digest(row[0], fingerprint)
else "conflict"
)
return outcome, {
"run_id": row[1],
"status": json.loads(row[2]),
"owner_pid": int(row[3] or 0),
"owner_started": int(row[4] or 0),
"updated_at": float(row[5] or 0),
}
self._conn.execute(
"INSERT INTO run_idempotency("
"scope,idempotency_key,fingerprint,run_id,status_json,"
"owner_pid,owner_started,retention_until,created_at,updated_at"
") VALUES(?,?,?,?,?,?,?,?,?,?)",
(
scope,
key,
fingerprint,
run_id,
encoded,
int(owner_pid or 0),
int(owner_started or 0),
retention_until,
now,
now,
),
)
self._conn.commit()
return "created", {
"run_id": run_id,
"status": status,
"owner_pid": int(owner_pid or 0),
"owner_started": int(owner_started or 0),
"updated_at": now,
}
except Exception:
self._conn.rollback()
raise
def lookup(
self,
scope: str,
key: str,
fingerprint: str,
*,
retention_until: float = 0,
):
"""Return ``missing``, ``reused`` or ``conflict`` without reserving."""
now = time.time()
retention_until = max(0.0, float(retention_until or 0))
with self._lock:
self._conn.execute("BEGIN IMMEDIATE")
try:
if retention_until:
self._conn.execute(
"""UPDATE run_idempotency
SET retention_until=MAX(retention_until, ?)
WHERE scope=? AND idempotency_key=?
AND fingerprint=?""",
(retention_until, scope, key, fingerprint),
)
self._prune_stale_terminal_locked(now)
row = self._conn.execute(
"SELECT fingerprint, run_id, status_json, owner_pid, owner_started, updated_at "
"FROM run_idempotency WHERE scope=? AND idempotency_key=?",
(scope, key),
).fetchone()
self._conn.commit()
except Exception:
self._conn.rollback()
raise
if row is None:
return "missing", None
outcome = "reused" if hmac.compare_digest(row[0], fingerprint) else "conflict"
return outcome, {
"run_id": row[1],
"status": json.loads(row[2]),
"owner_pid": int(row[3] or 0),
"owner_started": int(row[4] or 0),
"updated_at": float(row[5] or 0),
}
def _prune_stale_terminal_locked(self, now: float) -> None:
"""Prune replay records only after their stored run is terminal.
The caller owns ``self._lock`` and an active transaction. Age alone
can never release an in-flight idempotency reservation: a long or
disconnected room turn may legitimately outlive the retention window.
"""
stale = self._conn.execute(
"""SELECT scope, idempotency_key, status_json, retention_until,
acknowledged_at, updated_at
FROM run_idempotency
WHERE acknowledged_at <= ?
OR (retention_until > 0 AND retention_until <= ?)
OR (retention_until <= 0 AND updated_at < ?)""",
(
now - self.ACKNOWLEDGED_RETENTION_SECONDS,
now,
now - self.RETENTION_SECONDS,
),
).fetchall()
for (
stale_scope,
stale_key,
stale_status,
retention_until,
acknowledged_at,
updated_at,
) in stale:
try:
terminal = json.loads(stale_status).get("status") in {
"completed",
"failed",
"cancelled",
"interrupted",
}
except Exception:
terminal = False
expired = bool(
(
acknowledged_at is not None
and float(acknowledged_at)
<= now - self.ACKNOWLEDGED_RETENTION_SECONDS
)
or (
float(retention_until or 0) > 0
and now >= float(retention_until)
)
or (
float(retention_until or 0) <= 0
and float(updated_at or 0) < now - self.RETENTION_SECONDS
)
)
if terminal and expired:
self._conn.execute(
"""DELETE FROM run_idempotency
WHERE scope=? AND idempotency_key=?""",
(stale_scope, stale_key),
)
def status_for_run(
self,
scope: str,
run_id: str,
*,
retention_until: float = 0,
) -> dict[str, Any] | None:
"""Load one durable run status inside its authenticated scope."""
retention_until = max(0.0, float(retention_until or 0))
with self._lock:
if retention_until:
self._conn.execute(
"""UPDATE run_idempotency
SET retention_until=MAX(retention_until, ?)
WHERE scope=? AND run_id=?""",
(retention_until, scope, run_id),
)
self._conn.commit()
row = self._conn.execute(
"SELECT status_json, owner_pid, owner_started, updated_at "
"FROM run_idempotency WHERE scope=? AND run_id=?",
(scope, run_id),
).fetchone()
if row is None:
return None
return {
"status": json.loads(row[0]),
"owner_pid": int(row[1] or 0),
"owner_started": int(row[2] or 0),
"updated_at": float(row[3] or 0),
}
def acknowledge_terminal(self, scope: str, run_id: str) -> bool:
"""Allow cleanup once the room home durably imported terminal output."""
now = time.time()
with self._lock:
changed = self._conn.execute(
"""UPDATE run_idempotency SET acknowledged_at=?
WHERE scope=? AND run_id=?""",
(now, scope, run_id),
).rowcount
self._conn.commit()
return changed == 1
def extend_retention(self, scope: str, run_id: str, until: float) -> bool:
"""Persist the latest verified recovery horizon for an active grant."""
checked_until = max(0.0, float(until or 0))
if not checked_until:
return False
with self._lock:
changed = self._conn.execute(
"""UPDATE run_idempotency
SET retention_until=MAX(retention_until, ?)
WHERE scope=? AND run_id=?""",
(checked_until, scope, run_id),
).rowcount
self._conn.commit()
return changed == 1
def owns_run(self, scope: str, run_id: str) -> bool:
with self._lock:
return (
self._conn.execute(
"SELECT 1 FROM run_idempotency WHERE scope=? AND run_id=?",
(scope, run_id),
).fetchone()
is not None
)
def update_status(self, run_id: str, status: Dict[str, Any]) -> None:
encoded = json.dumps(status, sort_keys=True, separators=(",", ":"))
with self._lock:
self._conn.execute(
"UPDATE run_idempotency SET status_json=?, updated_at=? WHERE run_id=?",
(encoded, time.time(), run_id),
)
self._conn.commit()
def close(self) -> None:
with self._lock:
self._conn.close()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+942
View File
@@ -0,0 +1,942 @@
"""Shared helper classes for gateway platform adapters.
Extracts common patterns that were duplicated across 5-7 adapters:
message deduplication, text batch aggregation, markdown stripping,
and thread participation tracking.
"""
import asyncio
import json
import logging
import re
import time
from pathlib import Path
from typing import TYPE_CHECKING, Dict
from utils import atomic_json_write
if TYPE_CHECKING:
from gateway.platforms.base import MessageEvent
logger = logging.getLogger(__name__)
# ─── Message Deduplication ────────────────────────────────────────────────────
class MessageDeduplicator:
"""TTL-based message deduplication cache.
Replaces the identical ``_seen_messages`` / ``_is_duplicate()`` pattern
previously duplicated in discord, slack, dingtalk, wecom, weixin,
mattermost, and feishu adapters.
Usage::
self._dedup = MessageDeduplicator()
# In message handler:
if self._dedup.is_duplicate(msg_id):
return
"""
def __init__(self, max_size: int = 2000, ttl_seconds: float = 300):
self._seen: Dict[str, float] = {}
self._max_size = max_size
self._ttl = ttl_seconds
def is_duplicate(self, msg_id: str) -> bool:
"""Return True if *msg_id* was already seen within the TTL window."""
if not msg_id:
return False
now = time.time()
if msg_id in self._seen:
if now - self._seen[msg_id] < self._ttl:
return True
# Entry has expired — remove it and treat as new
del self._seen[msg_id]
self._seen[msg_id] = now
if len(self._seen) > self._max_size:
cutoff = now - self._ttl
self._seen = {k: v for k, v in self._seen.items() if v > cutoff}
if len(self._seen) > self._max_size:
# TTL pruning alone does not cap the cache when every entry is
# still fresh. Keep the newest entries so the helper's
# max_size bound is enforced under sustained traffic.
newest = sorted(
self._seen.items(),
key=lambda item: item[1],
)[-self._max_size:]
self._seen = dict(newest)
return False
def contains(self, msg_id: str) -> bool:
"""Return whether *msg_id* is live in the cache without inserting it."""
if not msg_id:
return False
seen_at = self._seen.get(msg_id)
if seen_at is None:
return False
if time.time() - seen_at < self._ttl:
return True
del self._seen[msg_id]
return False
def discard(self, msg_id: str) -> None:
"""Release a claimed message ID after cancelled/failed handoff."""
self._seen.pop(msg_id, None)
def clear(self):
"""Clear all tracked messages."""
self._seen.clear()
# ─── Text Batch Aggregation ──────────────────────────────────────────────────
class TextBatchAggregator:
"""Aggregates rapid-fire text events into single messages.
Replaces the ``_enqueue_text_event`` / ``_flush_text_batch`` pattern
previously duplicated in telegram, discord, matrix, wecom, and feishu.
Usage::
self._text_batcher = TextBatchAggregator(
handler=self._message_handler,
batch_delay=0.6,
split_threshold=1900,
)
# In message dispatch:
if msg_type == MessageType.TEXT and self._text_batcher.is_enabled():
self._text_batcher.enqueue(event, session_key)
return
"""
def __init__(
self,
handler,
*,
batch_delay: float = 0.6,
split_delay: float = 2.0,
split_threshold: int = 4000,
):
self._handler = handler
self._batch_delay = batch_delay
self._split_delay = split_delay
self._split_threshold = split_threshold
self._pending: Dict[str, "MessageEvent"] = {}
self._pending_tasks: Dict[str, asyncio.Task] = {}
def is_enabled(self) -> bool:
"""Return True if batching is active (delay > 0)."""
return self._batch_delay > 0
def enqueue(self, event: "MessageEvent", key: str) -> None:
"""Add *event* to the pending batch for *key*."""
chunk_len = len(event.text or "")
existing = self._pending.get(key)
if not existing:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending[key] = event
else:
existing.text = f"{existing.text}\n{event.text}"
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
# Cancel prior flush timer, start a new one
prior = self._pending_tasks.get(key)
if prior and not prior.done():
prior.cancel()
self._pending_tasks[key] = asyncio.create_task(self._flush(key))
async def _flush(self, key: str) -> None:
"""Wait then dispatch the batched event for *key*."""
current_task = self._pending_tasks.get(key)
pending = self._pending.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
# Use longer delay when the last chunk looks like a split message
delay = self._split_delay if last_len >= self._split_threshold else self._batch_delay
await asyncio.sleep(delay)
event = self._pending.pop(key, None)
if event:
try:
await self._handler(event)
except Exception:
logger.exception("[TextBatchAggregator] Error dispatching batched event for %s", key)
if self._pending_tasks.get(key) is current_task:
self._pending_tasks.pop(key, None)
def cancel_all(self) -> None:
"""Cancel all pending flush tasks."""
for task in self._pending_tasks.values():
if not task.done():
task.cancel()
self._pending_tasks.clear()
self._pending.clear()
# ─── Markdown Stripping ──────────────────────────────────────────────────────
# Pre-compiled regexes for performance
_RE_BOLD = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
_RE_ITALIC_STAR = re.compile(r"\*(.+?)\*", re.DOTALL)
_RE_BOLD_UNDER = re.compile(r"\b__(?![\s_])(.+?)(?<![\s_])__\b", re.DOTALL)
_RE_ITALIC_UNDER = re.compile(r"\b_(?![\s_])(.+?)(?<![\s_])_\b", re.DOTALL)
_RE_CODE_BLOCK = re.compile(r"```[a-zA-Z0-9_+-]*\n?")
_RE_INLINE_CODE = re.compile(r"`(.+?)`")
_RE_HEADING = re.compile(r"^#{1,6}\s+", re.MULTILINE)
_RE_LINK = re.compile(r"\[([^\]]+)\]\([^\)]+\)")
_RE_MULTI_NEWLINE = re.compile(r"\n{3,}")
def strip_markdown(text: str) -> str:
"""Strip markdown formatting for plain-text platforms (SMS, iMessage, etc.).
Replaces the identical ``_strip_markdown()`` functions previously
duplicated in sms.py, bluebubbles.py, and feishu.py.
"""
text = _RE_BOLD.sub(r"\1", text)
text = _RE_ITALIC_STAR.sub(r"\1", text)
text = _RE_BOLD_UNDER.sub(r"\1", text)
text = _RE_ITALIC_UNDER.sub(r"\1", text)
text = _RE_CODE_BLOCK.sub("", text)
text = _RE_INLINE_CODE.sub(r"\1", text)
text = _RE_HEADING.sub("", text)
text = _RE_LINK.sub(r"\1", text)
text = _RE_MULTI_NEWLINE.sub("\n\n", text)
return text.strip()
# ─── Thread Participation Tracking ───────────────────────────────────────────
class ThreadParticipationTracker:
"""Persistent tracking of threads the bot has participated in.
Replaces the identical ``_load/_save_participated_threads`` +
``_mark_thread_participated`` pattern previously duplicated in
discord.py and matrix.py.
Usage::
self._threads = ThreadParticipationTracker("discord")
# Check membership:
if thread_id in self._threads:
...
# Mark participation:
self._threads.mark(thread_id)
"""
_MAX_TRACKED = 500
def __init__(self, platform_name: str, max_tracked: int = 500):
self._platform = platform_name
self._max_tracked = max_tracked
self._threads: dict[str, None] = {
str(thread_id): None for thread_id in self._load()
}
def _state_path(self) -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / f"{self._platform}_threads.json"
def _load(self) -> list[str]:
path = self._state_path()
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, list):
return [str(thread_id) for thread_id in data]
except Exception:
pass
return []
def _save(self) -> None:
path = self._state_path()
thread_list = list(self._threads)
if len(thread_list) > self._max_tracked:
thread_list = thread_list[-self._max_tracked:]
self._threads = dict.fromkeys(thread_list)
atomic_json_write(path, thread_list, indent=None)
def mark(self, thread_id: str) -> None:
"""Mark *thread_id* as participated and persist."""
if thread_id not in self._threads:
self._threads[thread_id] = None
self._save()
def __contains__(self, thread_id: str) -> bool:
return thread_id in self._threads
def clear(self) -> None:
self._threads.clear()
# ─── Phone Number Redaction ──────────────────────────────────────────────────
def redact_phone(phone: str) -> str:
"""Redact a phone number for logging, preserving country code and last 4.
Replaces the identical ``_redact_phone()`` functions in signal.py,
sms.py, and bluebubbles.py.
"""
if not phone:
return "<none>"
if len(phone) <= 8:
return phone[:2] + "****" + phone[-2:] if len(phone) > 4 else "****"
return phone[:4] + "****" + phone[-4:]
# ─── GFM Markdown Table → Bullet Conversion ─────────────────────────────────
# Shared by Discord and Telegram adapters. Discord calls
# convert_table_to_bullets() directly; Telegram imports the primitives
# but keeps its own MarkdownV2-aware renderer.
# Matches a GFM table delimiter row: optional outer pipes, cells of dashes
# (with optional alignment colons) separated by '|'.
# Requires at least one internal '|' so lone '---' rules are NOT matched.
TABLE_SEPARATOR_RE = re.compile(
r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$'
)
def is_table_row(line: str) -> bool:
"""Return True if *line* could plausibly be a table data row."""
stripped = line.strip()
return bool(stripped) and '|' in stripped
def split_markdown_table_row(line: str) -> list[str]:
"""Split a GFM table row into stripped cell values.
Thin delegate to the canonical implementation in
:mod:`agent.markdown_tables` (``split_table_row``) so the three
formerly byte-identical copies (here, ``agent/markdown_tables.py``,
``weixin._split_table_row``) share one body.
"""
from agent.markdown_tables import split_table_row
return split_table_row(line)
def _render_table_block(table_block: list[str]) -> str:
"""Render a detected GFM table as bold-heading + bullet groups.
Uses the same alignment logic as Telegram's renderer: for non-row-label
tables, ``data_cells = cells`` (the full row) and the bullet whose value
duplicates the heading is skipped. This keeps header→value alignment
correct.
"""
if len(table_block) < 3:
return "\n".join(table_block)
headers = split_markdown_table_row(table_block[0])
if len(headers) < 2:
return "\n".join(table_block)
first_data_row = (
split_markdown_table_row(table_block[2])
if len(table_block) > 2
else []
)
has_row_label_col = len(first_data_row) == len(headers) + 1
rendered_groups: list[str] = []
for index, row in enumerate(table_block[2:], start=1):
cells = split_markdown_table_row(row)
if has_row_label_col:
heading = cells[0] if cells and cells[0] else f"Row {index}"
data_cells = cells[1:]
else:
heading = next((cell for cell in cells if cell), f"Row {index}")
data_cells = cells
if len(data_cells) < len(headers):
data_cells.extend([""] * (len(headers) - len(data_cells)))
elif len(data_cells) > len(headers):
data_cells = data_cells[: len(headers)]
bullets: list[str] = []
for header, value in zip(headers, data_cells):
if not has_row_label_col and value == heading:
continue
bullets.append(f"{header}: {value}")
group_lines = [f"**{heading}**", *bullets]
rendered_groups.append("\n".join(group_lines))
return "\n\n".join(rendered_groups)
def convert_table_to_bullets(text: str) -> str:
"""Rewrite GFM pipe tables into bold-heading + bullet groups.
Tables inside fenced code blocks are left alone.
"""
if '|' not in text or '-' not in text:
return text
lines = text.split('\n')
out: list[str] = []
in_fence = False
i = 0
while i < len(lines):
line = lines[i]
stripped = line.lstrip()
if stripped.startswith('```'):
in_fence = not in_fence
out.append(line)
i += 1
continue
if in_fence:
out.append(line)
i += 1
continue
if (
'|' in line
and i + 1 < len(lines)
and TABLE_SEPARATOR_RE.match(lines[i + 1])
):
table_block = [line, lines[i + 1]]
j = i + 2
while j < len(lines) and is_table_row(lines[j]):
table_block.append(lines[j])
j += 1
out.append(_render_table_block(table_block))
i = j
continue
out.append(line)
i += 1
return '\n'.join(out)
# ─── Mention-pattern compilation ─────────────────────────────────────────────
def compile_mention_patterns(
raw,
*,
log_prefix: str,
platform_label: str | None = None,
display_label: str | None = None,
defaults: 'list[str] | None' = None,
logger_: 'logging.Logger | None' = None,
) -> 'list[re.Pattern]':
"""Compile regex wake-word/mention patterns from config or env values.
Two adapter families share this logic:
* **Config-style** (dingtalk, telegram): pass ``platform_label`` (e.g.
``"dingtalk"``). ``raw`` is the value from ``config.extra`` after env
fallback parsing; must be a list or string, anything else logs a warning
and yields ``[]``. Non-string entries are skipped. A summary info log is
emitted when patterns load.
* **Wakeword-style** (photon, bluebubbles): pass ``defaults``. ``raw`` may
be None (use defaults), a string (JSON list or comma/newline separated),
a list, or a scalar (wrapped in a list). Entries are coerced via
``str()``.
``log_prefix`` is interpolated into every log message so per-adapter log
output stays byte-identical to the historical inline implementations.
"""
log = logger_ or logger
if platform_label is not None:
# Config-style (dingtalk/telegram) semantics.
display = display_label or platform_label
patterns = raw
if patterns is None:
return []
if isinstance(patterns, str):
patterns = [patterns]
if not isinstance(patterns, list):
log.warning(
"[%s] %s mention_patterns must be a list or string; got %s",
log_prefix,
platform_label,
type(patterns).__name__,
)
return []
compiled: list[re.Pattern] = []
for pattern in patterns:
if not isinstance(pattern, str) or not pattern.strip():
continue
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
log.warning(
"[%s] Invalid %s mention pattern %r: %s",
log_prefix,
display,
pattern,
exc,
)
if compiled:
log.info(
"[%s] Loaded %d %s mention pattern(s)",
log_prefix,
len(compiled),
display,
)
return compiled
# Wakeword-style (photon/bluebubbles) semantics.
if raw is None:
patterns = list(defaults or [])
elif isinstance(raw, str):
text = raw.strip()
try:
loaded = json.loads(text) if text else []
except Exception:
loaded = None
patterns = loaded if isinstance(loaded, list) else [
part.strip()
for line in text.splitlines()
for part in line.split(",")
]
elif isinstance(raw, list):
patterns = raw
else:
patterns = [raw]
compiled = []
for pattern in patterns:
text = str(pattern).strip()
if not text:
continue
try:
compiled.append(re.compile(text, re.IGNORECASE))
except re.error as exc:
log.warning("[%s] Invalid mention pattern %r: %s", log_prefix, text, exc)
return compiled
# ─── Fence-Aware Markdown Chunking ───────────────────────────────────────────
# Shared core for the fence-aware markdown chunkers that previously lived as
# near-duplicates in gateway/stream_consumer.py, gateway/platforms/yuanbao.py
# (MarkdownProcessor — the richest version, which this core is derived from),
# and gateway/platforms/weixin.py. Each caller keeps its own knobs:
#
# * stream_consumer: newline-preferred splitting + close/reopen fence
# balancing (``prefer_paragraphs=False, balance_fences=True``)
# * yuanbao: atomic-block extraction + paragraph-boundary splitting, fences
# kept intact as atoms (``prefer_paragraphs=True, balance_fences=False``)
# * weixin: keeps its own block splitter (anchored ``_FENCE_RE``, per-line
# rstrip semantics) but reuses ``greedy_pack_blocks`` for packing.
#
# The typing helpers below use ``Optional``/``Callable`` from ``typing`` to
# match the module's existing import style.
def text_has_unclosed_fence(text: str) -> bool:
"""Return True when *text* ends inside an unclosed ``` code fence.
Scans line by line, toggling in/out state on lines starting with ```.
An odd number of toggles means the trailing fence is unclosed.
"""
in_fence = False
for line in text.split('\n'):
if line.startswith('```'):
in_fence = not in_fence
return in_fence
def text_ends_with_table_row(text: str) -> bool:
"""True when the last non-empty line starts and ends with ``|``."""
trimmed = text.rstrip()
if not trimmed:
return False
last_line = trimmed.split('\n')[-1].strip()
return last_line.startswith('|') and last_line.endswith('|')
def is_fence_atom(text: str) -> bool:
"""True when an atomic block is a code block (starts with ```)."""
return text.lstrip().startswith('```')
def is_table_atom(text: str) -> bool:
"""True when an atomic block is a table (first line is ``|...|``)."""
first_line = text.split('\n')[0].strip()
return first_line.startswith('|') and first_line.endswith('|')
_SENTENCE_END_NEWLINE_RE = re.compile(r'[。!?.!?]\n')
def split_at_paragraph_boundary(text, max_chars, len_fn=None):
"""Find the nearest paragraph boundary within *max_chars*; return (head, tail).
Split priority:
1. Blank line (paragraph boundary)
2. Newline after sentence-ending punctuation (CJK and ASCII)
3. Last newline
4. Force split at the *max_chars* window boundary
``head + tail == text`` always holds. *len_fn* allows measuring in
custom units (e.g. UTF-16 code units); a binary search finds the largest
prefix that fits when it is provided.
"""
_len = len_fn or len
if _len(text) <= max_chars:
return text, ''
if _len is len:
window = text[:max_chars]
else:
lo, hi = 0, len(text)
while lo < hi:
mid = (lo + hi + 1) // 2
if _len(text[:mid]) <= max_chars:
lo = mid
else:
hi = mid - 1
window = text[:lo]
# 1. Prefer the last blank line (\n\n) as paragraph boundary
pos = window.rfind('\n\n')
if pos > 0:
return text[:pos + 2], text[pos + 2:]
# 2. Then the last newline following sentence-ending punctuation
best_pos = -1
for m in _SENTENCE_END_NEWLINE_RE.finditer(window):
best_pos = m.end()
if best_pos > 0:
return text[:best_pos], text[best_pos:]
# 3. Fallback: last newline
pos = window.rfind('\n')
if pos > 0:
return text[:pos + 1], text[pos + 1:]
# 4. No valid split point: force split at the window boundary
cut = len(window)
return text[:cut], text[cut:]
def split_markdown_atoms(text: str) -> "list[str]":
"""Split markdown into indivisible "atomic blocks".
Atoms are: fenced code blocks (``` ... ``` inclusive), tables
(consecutive ``|...|`` lines), and plain paragraphs separated by blank
lines. Blank lines are separators and belong to no atom.
"""
lines = text.split('\n')
atoms: "list[str]" = []
current_lines: "list[str]" = []
in_fence = False
def _is_table_line(line: str) -> bool:
stripped = line.strip()
return stripped.startswith('|') and stripped.endswith('|')
def _flush_current() -> None:
if current_lines:
atom = '\n'.join(current_lines)
if atom.strip():
atoms.append(atom)
current_lines.clear()
for line in lines:
if in_fence:
current_lines.append(line)
if line.startswith('```') and len(current_lines) > 1:
in_fence = False
_flush_current()
elif line.startswith('```'):
_flush_current()
in_fence = True
current_lines.append(line)
elif _is_table_line(line):
if current_lines and not _is_table_line(current_lines[-1]):
_flush_current()
current_lines.append(line)
elif line.strip() == '':
_flush_current()
else:
if current_lines and _is_table_line(current_lines[-1]):
_flush_current()
current_lines.append(line)
_flush_current()
return atoms
def infer_block_separator(prev_chunk: str, next_chunk: str) -> str:
"""Infer the separator (``'\\n'`` or ``'\\n\\n'``) between two chunks.
Single newline when the boundary sits at a code fence or a continued
table; paragraph separator otherwise.
"""
prev_trimmed = prev_chunk.rstrip()
next_trimmed = next_chunk.lstrip()
if prev_trimmed.endswith('```') or next_trimmed.startswith('```'):
return '\n'
if text_ends_with_table_row(prev_chunk):
first_line = next_trimmed.split('\n')[0].strip() if next_trimmed else ''
if first_line.startswith('|') and first_line.endswith('|'):
return '\n'
return '\n\n'
def merge_streaming_fences(chunks: "list[str]") -> "list[str]":
"""Stream-aware fence merge: rejoin chunks truncated mid-fence.
While chunk *i* has an unclosed fence and a successor exists, merge the
successor into it using :func:`infer_block_separator`.
"""
if not chunks:
return []
result: "list[str]" = []
i = 0
while i < len(chunks):
current = chunks[i]
while text_has_unclosed_fence(current) and i + 1 < len(chunks):
sep = infer_block_separator(current, chunks[i + 1])
current = current + sep + chunks[i + 1]
i += 1
result.append(current)
i += 1
return result
def balance_fences_across_chunks(chunks: "list[str]") -> "list[str]":
"""Close orphaned ``` fences at each chunk boundary and reopen on the next.
When a split lands inside a triple-backtick code block, close the fence
at the end of the head chunk and reopen it (with the original language
tag) at the start of the next, so every delivered chunk is
fence-balanced on its own.
"""
if len(chunks) <= 1:
return chunks
out: "list[str]" = []
carry_lang = None
for chunk in chunks:
prefix = f"```{carry_lang}\n" if carry_lang is not None else ""
in_code = carry_lang is not None
lang = carry_lang or ""
for line in chunk.split("\n"):
stripped = line.strip()
if stripped.startswith("```"):
if in_code:
in_code = False
lang = ""
else:
in_code = True
tag = stripped[3:].strip()
lang = tag.split()[0] if tag else ""
body = prefix + chunk
if in_code:
body += "\n```"
carry_lang = lang
else:
carry_lang = None
out.append(body)
return out
def greedy_pack_blocks(blocks, max_length, len_fn=None, sep="\n\n", overflow=None):
"""Greedily pack pre-split *blocks* into chunks of at most *max_length*.
Blocks are joined with *sep* while they fit. A block that alone exceeds
the limit is passed to *overflow(block)* (which must return a list of
chunks) when provided, else emitted as-is.
"""
_len = len_fn or len
packed: "list[str]" = []
current = ""
for block in blocks:
candidate = block if not current else f"{current}{sep}{block}"
if _len(candidate) <= max_length:
current = candidate
continue
if current:
packed.append(current)
current = ""
if _len(block) <= max_length:
current = block
continue
if overflow is not None:
packed.extend(overflow(block))
else:
packed.append(block)
if current:
packed.append(current)
return packed
def split_text_fence_aware(
text,
limit,
len_fn=None,
*,
prefer_paragraphs=True,
balance_fences=False,
):
"""Split markdown text into chunks of at most *limit*, respecting fences.
Two strategies, selected by ``prefer_paragraphs``:
``prefer_paragraphs=True`` (yuanbao-derived, the richest):
Extract atomic blocks (code fences, tables, paragraphs), greedily merge
them up to *limit*, split still-oversized non-atomic chunks at
paragraph boundaries, then re-merge small neighbours. Code blocks and
tables are never split in the middle; a single atom larger than
*limit* is emitted oversize rather than broken.
``prefer_paragraphs=False`` (stream_consumer-derived):
Newline-preferred hard splitting with headroom reserved for fence
markers when the text contains ```.
``balance_fences=True`` post-processes the chunks so a split inside a
code block closes the fence on the head chunk and reopens it (with the
language tag) on the tail — required by callers whose chunks are
delivered as independent messages that each must render standalone.
"""
_len = len_fn or len
if not text:
return []
if prefer_paragraphs:
chunks = _chunk_markdown_paragraphs(text, limit, len_fn)
else:
chunks = _chunk_newline_preferred(text, limit, _len)
if balance_fences:
chunks = balance_fences_across_chunks(chunks)
return chunks
def _chunk_markdown_paragraphs(text, max_chars, len_fn=None):
"""Yuanbao-derived paragraph/atom chunking pipeline (see module docs)."""
_len = len_fn or len
if _len(text) <= max_chars:
return [text]
# Phase 1: Extract atomic blocks
atoms = split_markdown_atoms(text)
# Phase 2: Greedy merge
chunks: "list[str]" = []
indivisible_set: "set[int]" = set()
current_parts: "list[str]" = []
current_len = 0
def _flush_parts() -> None:
if current_parts:
chunks.append('\n\n'.join(current_parts))
for atom in atoms:
atom_len = _len(atom)
sep_len = 2 if current_parts else 0
projected_len = current_len + sep_len + atom_len
if projected_len > max_chars and current_parts:
_flush_parts()
current_parts = []
current_len = 0
sep_len = 0
if (not current_parts
and atom_len > max_chars
and (is_fence_atom(atom) or is_table_atom(atom))):
indivisible_set.add(len(chunks))
chunks.append(atom)
continue
current_parts.append(atom)
current_len += sep_len + atom_len
_flush_parts()
# Phase 3: Split still-oversized chunks at paragraph boundaries
result: "list[str]" = []
for idx, chunk in enumerate(chunks):
if _len(chunk) <= max_chars:
result.append(chunk)
continue
if idx in indivisible_set:
result.append(chunk)
continue
if text_has_unclosed_fence(chunk):
result.append(chunk)
continue
remaining = chunk
while _len(remaining) > max_chars:
head, remaining = split_at_paragraph_boundary(
remaining, max_chars, len_fn=len_fn,
)
if not head:
head, remaining = remaining[:max_chars], remaining[max_chars:]
if head:
result.append(head)
if remaining:
result.append(remaining)
# Phase 4: Merge small trailing/leading chunks with neighbours
if len(result) > 1:
merged: "list[str]" = [result[0]]
for chunk in result[1:]:
prev = merged[-1]
combined = prev + '\n\n' + chunk
if _len(combined) <= max_chars:
merged[-1] = combined
else:
merged.append(chunk)
result = merged
return [c for c in result if c]
def _chunk_newline_preferred(text, limit, len_fn):
"""Stream-consumer-derived newline-preferred splitting (no balancing)."""
if len_fn(text) <= limit:
return [text]
# Reserve headroom for the close/reopen fence markers a balancing pass
# may add, so balanced chunks stay within the platform limit.
split_limit = limit
if "```" in text:
split_limit = max(limit - 16, limit // 2, 1)
# Local import: gateway.platforms.base is heavyweight and pulls config;
# helpers must stay import-light for adapters that import it first.
from gateway.platforms.base import _custom_unit_to_cp
chunks: "list[str]" = []
remaining = text
while len_fn(remaining) > split_limit:
_cp_budget = _custom_unit_to_cp(remaining, split_limit, len_fn)
split_at = remaining.rfind("\n", 0, _cp_budget)
if split_at < _cp_budget // 2:
split_at = _cp_budget
chunks.append(remaining[:split_at])
remaining = remaining[split_at:].lstrip("\n")
if remaining:
chunks.append(remaining)
return chunks
+202
View File
@@ -0,0 +1,202 @@
"""Shared mime↔extension dispatch for inbound (downloaded) platform media.
Historically every gateway adapter hand-rolled its own mime→extension map
before handing downloaded bytes to the cache primitives in
``gateway.platforms.base`` (``cache_image_from_bytes``,
``cache_audio_from_bytes``, ``cache_document_from_bytes``). Those maps
*disagree* with each other on purpose — e.g. BlueBubbles coerces
``image/heic`` to ``.jpg`` because downstream vision tools can't read HEIC,
while WhatsApp Cloud pins ``audio/ogg`` to ``.ogg`` (not the RFC-correct
``.oga`` Python's ``mimetypes`` returns) because the STT pipeline whitelists
extensions.
This module owns:
* ``DEFAULT_MIME_TO_EXT`` — the union table of entries the adapters already
agree on (plus a few uncontroversial document types).
* ``DEFAULT_EXT_TO_MIME`` — the canonical inverse (used by Signal to map a
sniffed extension back to a content type).
* ``ext_for_mime`` / ``mime_for_ext`` — lookup helpers that accept
per-adapter ``overrides`` so each adapter's historical (divergent)
behavior is preserved byte-for-byte.
* ``cache_media_bytes`` — one-call dispatch: classify the mime, resolve the
extension, and write to the right cache (image / audio / document).
Behavior-preservation contract: adapters that had divergent maps pass them
as ``overrides`` (and, where their historical code never consulted
``mimetypes`` or a shared table, disable those fallbacks via
``use_defaults`` / ``use_mimetypes``). The parity tests in
``tests/gateway/test_media_cache.py`` hardcode the historical outputs as
the contract.
NOTE: ``gateway/platforms/weixin.py`` also has a private mime map
(``_mime_from_filename``) but is intentionally NOT migrated here — another
in-flight branch edits that file. Follow-up: fold it in once that lands.
"""
from __future__ import annotations
import mimetypes
import uuid
from typing import Mapping, Optional
# ---------------------------------------------------------------------------
# Shared tables
# ---------------------------------------------------------------------------
# Union of the per-adapter maps where the adapters already agree (or where
# only one adapter pinned the type and no other adapter contradicts it).
# Entries deliberately favor the common-in-the-wild extension over the
# RFC-correct one (``audio/ogg`` → ``.ogg``, not ``.oga``) because the
# downstream STT/vision pipelines whitelist real-world extensions.
DEFAULT_MIME_TO_EXT: dict[str, str] = {
# --- images (bluebubbles + whatsapp_cloud agree; matches mimetypes) ---
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
# --- audio ---
"audio/ogg": ".ogg", # bluebubbles + whatsapp_cloud agree
"audio/x-opus+ogg": ".ogg", # whatsapp voice notes (opus-in-ogg)
"audio/opus": ".ogg", # whatsapp voice notes (opus-in-ogg)
"audio/mpeg": ".mp3",
"audio/mp3": ".mp3", # non-standard but seen in the wild
"audio/wav": ".wav",
"audio/mp4": ".m4a", # bluebubbles + whatsapp_cloud agree
"audio/x-m4a": ".m4a",
"audio/aac": ".aac",
# --- video / documents (from signal's inverse table) ---
"video/mp4": ".mp4",
"application/pdf": ".pdf",
"application/zip": ".zip",
}
# Canonical inverse. Kept explicit (rather than mechanically inverted)
# because the forward table is many-to-one — e.g. both ``audio/mpeg`` and
# ``audio/mp3`` map to ``.mp3`` and the inverse must pick the canonical
# mime. This is byte-identical to Signal's historical ``_EXT_TO_MIME``.
DEFAULT_EXT_TO_MIME: dict[str, str] = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".gif": "image/gif", ".webp": "image/webp",
".ogg": "audio/ogg", ".mp3": "audio/mpeg", ".wav": "audio/wav",
".m4a": "audio/mp4", ".aac": "audio/aac",
".mp4": "video/mp4", ".pdf": "application/pdf",
".zip": "application/zip",
}
def _normalize_mime(mime: str) -> str:
"""Lowercase and strip any ``; charset=...`` style parameters."""
return (mime or "").split(";")[0].strip().lower()
# ---------------------------------------------------------------------------
# Lookups
# ---------------------------------------------------------------------------
def ext_for_mime(
mime: str,
*,
overrides: Optional[Mapping[str, str]] = None,
use_defaults: bool = True,
use_mimetypes: bool = True,
fallback: Optional[str] = None,
) -> Optional[str]:
"""Resolve a mime type to a file extension (including the dot).
Resolution order: ``overrides`` → ``DEFAULT_MIME_TO_EXT`` (if
``use_defaults``) → ``mimetypes.guess_extension`` (if
``use_mimetypes``) → ``fallback``.
Adapters with historical divergent maps pass them via ``overrides``
and disable the stages their old code never consulted, keeping their
outputs byte-identical to the pre-refactor behavior.
"""
primary = _normalize_mime(mime)
if not primary:
return fallback
if overrides:
ext = overrides.get(primary)
if ext:
return ext
if use_defaults:
ext = DEFAULT_MIME_TO_EXT.get(primary)
if ext:
return ext
if use_mimetypes:
ext = mimetypes.guess_extension(primary)
if ext:
return ext
return fallback
def mime_for_ext(
ext: str,
*,
overrides: Optional[Mapping[str, str]] = None,
fallback: str = "application/octet-stream",
) -> str:
"""Inverse lookup: file extension → canonical mime type.
Resolution order: ``overrides`` → ``DEFAULT_EXT_TO_MIME`` → ``fallback``.
"""
key = (ext or "").strip().lower()
if overrides:
mime = overrides.get(key)
if mime:
return mime
return DEFAULT_EXT_TO_MIME.get(key, fallback)
# ---------------------------------------------------------------------------
# One-call cache dispatch
# ---------------------------------------------------------------------------
def cache_media_bytes(
data: bytes,
mime: str,
*,
filename_hint: str = "",
kind_hint: Optional[str] = None,
ext_overrides: Optional[Mapping[str, str]] = None,
) -> str:
"""Cache downloaded media bytes and return the local file path.
Picks the image / audio / document cache primitive from
``gateway.platforms.base`` based on the mime class (or an explicit
``kind_hint`` of ``"image"``, ``"audio"`` or ``"document"``).
``filename_hint`` is used for document caching (falls back to a
generated name with the resolved extension). ``ext_overrides`` is
threaded through to :func:`ext_for_mime` for adapters that need their
historical mappings.
"""
# Local import: base is a large module and some adapters import this
# module very early; keep import-time coupling minimal.
from gateway.platforms.base import (
cache_audio_from_bytes,
cache_document_from_bytes,
cache_image_from_bytes,
)
primary = _normalize_mime(mime)
kind = kind_hint
if kind is None:
if primary.startswith("image/"):
kind = "image"
elif primary.startswith("audio/"):
kind = "audio"
else:
kind = "document"
if kind == "image":
ext = ext_for_mime(primary, overrides=ext_overrides, fallback=".jpg") or ".jpg"
return cache_image_from_bytes(data, ext)
if kind == "audio":
ext = ext_for_mime(primary, overrides=ext_overrides, fallback=".ogg") or ".ogg"
return cache_audio_from_bytes(data, ext)
filename = filename_hint
if not filename:
ext = ext_for_mime(primary, overrides=ext_overrides, fallback=".bin")
filename = f"file_{uuid.uuid4().hex[:8]}{ext}"
return cache_document_from_bytes(data, filename)
+457
View File
@@ -0,0 +1,457 @@
"""Microsoft Graph webhook adapter for change-notification ingress."""
from __future__ import annotations
import asyncio
import hmac
import ipaddress
import json
import logging
from collections import deque
from hashlib import sha1
from typing import Any, Awaitable, Callable, Dict, Optional
try:
from aiohttp import web
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
web = None # type: ignore[assignment]
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
is_network_accessible,
)
logger = logging.getLogger(__name__)
# ``None`` → aiohttp/asyncio ``create_server`` binds one listening socket per
# address family (IPv4 + IPv6). The old "0.0.0.0" default bound IPv4 ONLY and
# was unreachable over IPv6-only private networks (e.g. Fly.io 6PN) — same
# bug as the LINE adapter (NS-603) and gateway/platforms/webhook.py
# (d542894ad). Pin a host via extra.host. The all-interfaces default still
# requires extra.allowed_source_cidrs (see _source_allowlist_required_but_missing).
DEFAULT_HOST = None
DEFAULT_PORT = 8646
DEFAULT_WEBHOOK_PATH = "/msgraph/webhook"
DEFAULT_MAX_SEEN_RECEIPTS = 5000
DEFAULT_MAX_BODY_BYTES = 1_048_576
NotificationScheduler = Callable[[Dict[str, Any], MessageEvent], Awaitable[None] | None]
def check_msgraph_webhook_requirements() -> bool:
"""Return whether required webhook dependencies are available."""
return AIOHTTP_AVAILABLE
class MSGraphWebhookAdapter(BasePlatformAdapter):
"""Receive Microsoft Graph change notifications and surface them internally."""
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MSGRAPH_WEBHOOK)
extra = config.extra or {}
# Falsy host (None/"") collapses to the dual-stack default.
_raw_host = extra.get("host", DEFAULT_HOST) or DEFAULT_HOST
self._host: Optional[str] = str(_raw_host) if _raw_host else None
self._port: int = int(extra.get("port", DEFAULT_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._accepted_resources: list[str] = [
str(value).strip()
for value in (extra.get("accepted_resources") or [])
if str(value).strip()
]
self._client_state: Optional[str] = self._string_or_none(extra.get("client_state"))
self._max_seen_receipts = max(
1, int(extra.get("max_seen_receipts", DEFAULT_MAX_SEEN_RECEIPTS))
)
self._max_body_bytes = max(
1, int(extra.get("max_body_bytes", DEFAULT_MAX_BODY_BYTES))
)
self._allowed_source_networks: list[ipaddress._BaseNetwork] = (
self._parse_allowed_source_cidrs(extra.get("allowed_source_cidrs"))
)
self._runner = None
self._notification_scheduler: Optional[NotificationScheduler] = None
self._seen_receipts: set[str] = set()
self._seen_receipt_order: deque[str] = deque()
self._accepted_count = 0
self._duplicate_count = 0
@staticmethod
def _string_or_none(value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
return text or None
@staticmethod
def _normalize_path(path: Any) -> str:
raw = str(path or "").strip() or "/"
return raw if raw.startswith("/") else f"/{raw}"
@staticmethod
def _build_receipt_key(notification: Dict[str, Any]) -> Optional[str]:
explicit_id = str(notification.get("id") or "").strip()
if explicit_id:
return f"id:{explicit_id}"
return None
@staticmethod
def _normalize_resource_value(resource: str) -> str:
return str(resource or "").strip().strip("/")
@staticmethod
def _parse_allowed_source_cidrs(
raw: Any,
) -> list[ipaddress._BaseNetwork]:
"""Parse an optional list of CIDR ranges allowed to POST to the webhook.
An empty or missing value means "allow everything" (same behavior as
before this field existed). When populated, requests from source IPs
outside every listed CIDR are rejected with 403 before the body is
parsed. Use this to restrict the endpoint to Microsoft Graph's
published webhook source ranges in production deployments.
"""
if raw is None:
return []
if isinstance(raw, str):
candidates = [chunk.strip() for chunk in raw.split(",")]
elif isinstance(raw, (list, tuple, set)):
candidates = [str(chunk).strip() for chunk in raw]
else:
return []
networks: list[ipaddress._BaseNetwork] = []
for chunk in candidates:
if not chunk:
continue
try:
networks.append(ipaddress.ip_network(chunk, strict=False))
except ValueError:
logger.warning(
"[msgraph_webhook] Ignoring invalid allowed_source_cidrs entry: %r",
chunk,
)
return networks
def set_notification_scheduler(self, scheduler: Optional[NotificationScheduler]) -> None:
self._notification_scheduler = scheduler
def _source_allowlist_required_but_missing(self) -> bool:
# host=None binds all interfaces (both families) — network-accessible.
host_is_public = self._host is None or is_network_accessible(self._host)
return host_is_public and not self._allowed_source_networks
async def connect(self, *, is_reconnect: bool = False) -> bool:
if self._client_state is None:
logger.error(
"[msgraph_webhook] Refusing to start without extra.client_state configured"
)
return False
if self._source_allowlist_required_but_missing():
logger.error(
"[msgraph_webhook] Refusing to start: binding to %s requires "
"extra.allowed_source_cidrs. Configure the Microsoft Graph "
"source CIDRs or bind to loopback (127.0.0.1/::1) behind a "
"tunnel or reverse proxy.",
self._host,
)
return False
app = web.Application(client_max_size=self._max_body_bytes)
app.router.add_get(self._health_path, self._handle_health)
app.router.add_get(self._webhook_path, self._handle_validation)
app.router.add_post(self._webhook_path, self._handle_notification)
# Plugin-registered native handlers (aiohttp web.Application —
# router routes). Wired before AppRunner.setup() freezes the router.
self._wire_plugin_handlers(app)
self._runner = web.AppRunner(app)
await self._runner.setup()
site = web.TCPSite(self._runner, self._host, self._port)
await site.start()
self._mark_connected()
logger.info(
"[msgraph_webhook] Listening on %s:%d%s",
self._host,
self._port,
self._webhook_path,
)
return True
async def disconnect(self) -> None:
if self._runner is not None:
await self._runner.cleanup()
self._runner = None
self._mark_disconnected()
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
logger.info("[msgraph_webhook] Response for %s: %s", chat_id, content[:200])
return SendResult(success=True)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": chat_id, "type": "webhook"}
async def _handle_health(self, request: "web.Request") -> "web.Response":
if not self._source_ip_allowed(request):
return web.Response(status=403)
return web.json_response(
{
"status": "ok",
"platform": self.platform.value,
"webhook_path": self._webhook_path,
"accepted": self._accepted_count,
"duplicates": self._duplicate_count,
}
)
async def _handle_validation(self, request: "web.Request") -> "web.Response":
"""Handle Microsoft Graph subscription validation handshake.
Graph validates a subscription endpoint by sending a GET with
``validationToken`` in the query string; the service must echo the
token verbatim as ``text/plain`` within 10 seconds. Anything else
(bare GET, GET without the token) is rejected so the endpoint can't
be enumerated or mistakenly used for data exfiltration.
"""
if not self._source_ip_allowed(request):
return web.Response(status=403)
validation_token = request.query.get("validationToken", "")
if not validation_token:
return web.Response(status=400)
return web.Response(text=validation_token, content_type="text/plain")
async def _handle_notification(self, request: "web.Request") -> "web.Response":
if not self._source_ip_allowed(request):
return web.Response(status=403)
# Graph never sends validationToken on POST, but tolerate it for
# defensive clients that replay the handshake in-band.
validation_token = request.query.get("validationToken", "")
if validation_token:
return web.Response(text=validation_token, content_type="text/plain")
try:
content_length = request.content_length
except Exception:
content_length = None
if content_length is not None and content_length > self._max_body_bytes:
return web.Response(status=413)
try:
raw_body = await request.read()
except Exception:
return web.Response(status=400)
if len(raw_body) > self._max_body_bytes:
return web.Response(status=413)
try:
body = json.loads(raw_body.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
return web.Response(status=400)
if not isinstance(body, dict):
return web.Response(status=400)
notifications = body.get("value")
if not isinstance(notifications, list):
return web.Response(status=400)
accepted = 0
duplicates = 0
auth_rejected = 0
other_rejected = 0
for raw_notification in notifications:
if not isinstance(raw_notification, dict):
other_rejected += 1
continue
notification = dict(raw_notification)
if not self._resource_accepted(str(notification.get("resource") or "")):
other_rejected += 1
continue
if not self._verify_client_state(notification):
# Treat bad clientState as an auth failure: if the whole
# batch is forged, we want to signal 403 so the sender
# stops retrying. Legitimate Graph retries have valid
# clientState and hit the accepted/duplicate paths.
auth_rejected += 1
continue
receipt_key = self._build_receipt_key(notification)
if receipt_key is not None:
if self._has_seen_receipt(receipt_key):
duplicates += 1
continue
self._remember_receipt(receipt_key)
accepted += 1
self._accepted_count += 1
event = self._build_message_event(notification, receipt_key)
self._schedule_notification(notification, event)
self._duplicate_count += duplicates
# If anything ingested OR deduped, return 202 with empty body so
# Graph acks successfully and we don't leak internal counters. If
# every item failed auth, return 403 so an attacker POSTing fake
# notifications gets a clear reject. Other failures (malformed,
# resource-not-accepted) are the sender's configuration problem,
# so 400.
if accepted or duplicates:
return web.Response(status=202)
if auth_rejected and not other_rejected:
return web.Response(status=403)
return web.Response(status=400)
def _source_ip_allowed(self, request: "web.Request") -> bool:
"""Return True if the request's source IP is in the configured allowlist.
Loopback-only binds may omit ``allowed_source_cidrs`` for local reverse
proxies and dev tunnels. Network-accessible binds fail closed until an
explicit CIDR allowlist is configured.
"""
if self._source_allowlist_required_but_missing():
return False
if not self._allowed_source_networks:
return True
peer = request.remote or ""
if not peer:
return False
try:
peer_addr = ipaddress.ip_address(peer)
except ValueError:
return False
return any(peer_addr in network for network in self._allowed_source_networks)
def _resource_accepted(self, resource: str) -> bool:
if not self._accepted_resources:
return True
normalized_resource = self._normalize_resource_value(resource)
for pattern in self._accepted_resources:
normalized_pattern = self._normalize_resource_value(pattern)
if not normalized_pattern:
continue
if normalized_pattern.endswith("*"):
prefix = normalized_pattern[:-1].rstrip("/")
if normalized_resource == prefix or normalized_resource.startswith(f"{prefix}/"):
return True
continue
if (
normalized_resource == normalized_pattern
or normalized_resource.startswith(f"{normalized_pattern}/")
):
return True
return False
def _verify_client_state(self, notification: Dict[str, Any]) -> bool:
"""Verify the Graph-supplied clientState matches the configured secret.
Uses ``hmac.compare_digest`` instead of ``==`` so that a mismatch
doesn't leak how many leading characters matched via string-compare
timing. The configured client_state is a shared secret (documented in
the setup guide as "generate with ``openssl rand -hex 32``"), so a
timing-safe compare is the right primitive.
"""
expected = self._client_state
if expected is None:
return False
provided = self._string_or_none(notification.get("clientState"))
if provided is None:
return False
# Compare as bytes: ``compare_digest`` raises TypeError on a str with
# non-ASCII characters, and clientState comes from the request body.
return hmac.compare_digest(provided.encode(), expected.encode())
def _has_seen_receipt(self, receipt_key: str) -> bool:
return receipt_key in self._seen_receipts
def _remember_receipt(self, receipt_key: str) -> None:
self._seen_receipts.add(receipt_key)
self._seen_receipt_order.append(receipt_key)
while len(self._seen_receipt_order) > self._max_seen_receipts:
oldest = self._seen_receipt_order.popleft()
self._seen_receipts.discard(oldest)
def _build_message_event(
self,
notification: Dict[str, Any],
receipt_key: Optional[str],
) -> MessageEvent:
message_id = receipt_key or f"sha1:{sha1(json.dumps(notification, sort_keys=True).encode('utf-8')).hexdigest()}"
source = self.build_source(
chat_id=f"msgraph:{notification.get('subscriptionId', 'unknown')}",
chat_name="msgraph/webhook",
chat_type="webhook",
user_id="msgraph",
user_name="Microsoft Graph",
)
return MessageEvent(
text=self._render_prompt(notification),
message_type=MessageType.TEXT,
source=source,
raw_message=notification,
message_id=message_id,
internal=True,
)
def _render_prompt(self, notification: Dict[str, Any]) -> str:
template = self.config.extra.get("prompt", "")
if template:
payload = {
"notification": notification,
"resource": notification.get("resource", ""),
"change_type": notification.get("changeType", ""),
"subscription_id": notification.get("subscriptionId", ""),
}
return self._render_template(template, payload)
rendered = json.dumps(notification, indent=2, sort_keys=True)[:4000]
return f"Microsoft Graph change notification:\n\n```json\n{rendered}\n```"
def _render_template(self, template: str, payload: Dict[str, Any]) -> str:
import re
def _resolve(match: "re.Match[str]") -> str:
key = match.group(1)
value: Any = payload
for part in key.split("."):
if isinstance(value, dict):
value = value.get(part, f"{{{key}}}")
else:
return f"{{{key}}}"
if isinstance(value, (dict, list)):
return json.dumps(value, sort_keys=True)[:2000]
return str(value)
return re.sub(r"\{([a-zA-Z0-9_.]+)\}", _resolve, template)
def _schedule_notification(
self,
notification: Dict[str, Any],
event: MessageEvent,
) -> None:
scheduler = self._notification_scheduler
if scheduler is not None:
result = scheduler(notification, event)
if asyncio.iscoroutine(result):
task = asyncio.create_task(result)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
return
task = asyncio.create_task(self.handle_message(event))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
+91
View File
@@ -0,0 +1,91 @@
"""
QQBot platform package.
Re-exports the main adapter symbols from ``adapter.py`` (the original
``qqbot.py``) so that **all existing import paths remain unchanged**::
from gateway.platforms.qqbot import QQAdapter # works
from gateway.platforms.qqbot import check_qq_requirements # works
New modules:
- ``constants`` — shared constants (API URLs, timeouts, message types)
- ``utils`` — User-Agent builder, config helpers
- ``crypto`` — AES-256-GCM key generation and decryption
- ``onboard`` — QR-code scan-to-configure flow
"""
# -- Adapter (original qqbot.py) ------------------------------------------
from .adapter import ( # noqa: F401
QQAdapter,
QQCloseError,
check_qq_requirements,
_coerce_list,
_ssrf_redirect_guard,
)
# -- Onboard (QR-code scan-to-configure) -----------------------------------
from .onboard import ( # noqa: F401
BindStatus,
build_connect_url,
qr_register,
)
from .crypto import decrypt_secret, generate_bind_key # noqa: F401
# -- Utils -----------------------------------------------------------------
from .utils import build_user_agent, get_api_headers, coerce_list # noqa: F401
# -- Chunked upload --------------------------------------------------------
from .chunked_upload import ( # noqa: F401
ChunkedUploader,
UploadDailyLimitExceededError,
UploadFileTooLargeError,
)
# -- Inline keyboards ------------------------------------------------------
from .keyboards import ( # noqa: F401
ApprovalRequest,
ApprovalSender,
InlineKeyboard,
InteractionEvent,
build_approval_keyboard,
build_approval_text,
build_update_prompt_keyboard,
parse_approval_button_data,
parse_interaction_event,
parse_update_prompt_button_data,
)
__all__ = [
# adapter
"QQAdapter",
"QQCloseError",
"check_qq_requirements",
"_coerce_list",
"_ssrf_redirect_guard",
# onboard
"BindStatus",
"build_connect_url",
"qr_register",
# crypto
"decrypt_secret",
"generate_bind_key",
# utils
"build_user_agent",
"get_api_headers",
"coerce_list",
# chunked upload
"ChunkedUploader",
"UploadDailyLimitExceededError",
"UploadFileTooLargeError",
# keyboards
"ApprovalRequest",
"ApprovalSender",
"InlineKeyboard",
"InteractionEvent",
"build_approval_keyboard",
"build_approval_text",
"build_update_prompt_keyboard",
"parse_approval_button_data",
"parse_interaction_event",
"parse_update_prompt_button_data",
]
File diff suppressed because it is too large Load Diff
+602
View File
@@ -0,0 +1,602 @@
"""QQ Bot chunked upload flow.
The QQ v2 API caps inline base64 uploads (``file_data`` / ``url``) at ~10 MB.
For files between 10 MB and ~100 MB we have to use the three-step chunked
upload flow::
1. POST /v2/{users|groups}/{id}/upload_prepare
→ returns upload_id, block_size, and an array of pre-signed COS part URLs.
2. For each part:
PUT the part bytes to its pre-signed COS URL,
then POST /v2/{users|groups}/{id}/upload_part_finish to acknowledge.
3. POST /v2/{users|groups}/{id}/files with {"upload_id": ...}
→ returns the ``file_info`` token the caller uses in a RichMedia
message.
Error-code semantics (from the QQ Bot v2 API spec):
- ``40093001`` — ``upload_part_finish`` retryable. Retry until the server-provided
``retry_timeout`` elapses (or a local cap).
- ``40093002`` — daily cumulative upload quota exceeded. Not retryable; surface
as :class:`UploadDailyLimitExceededError` so the caller can build a
user-friendly reply.
Exceptions:
- :class:`UploadDailyLimitExceededError` — daily quota hit (non-retryable).
- :class:`UploadFileTooLargeError` — file exceeds the platform per-file limit.
- :class:`RuntimeError` — generic upload failure (network, part PUT, complete).
Ported from WideLee's qqbot-agent-sdk v1.2.2 (``media_loader.py::ChunkedUploader``)
so the heavy-upload path stays in-tree. Authorship preserved via Co-authored-by.
"""
from __future__ import annotations
import asyncio
import functools
import hashlib
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional
from gateway.platforms.qqbot.constants import FILE_UPLOAD_TIMEOUT
logger = logging.getLogger(__name__)
# ── Error codes ──────────────────────────────────────────────────────
_BIZ_CODE_DAILY_LIMIT = 40093002 # upload_prepare: daily cumulative limit
_BIZ_CODE_PART_RETRYABLE = 40093001 # upload_part_finish: transient
# ── Part upload tuning ───────────────────────────────────────────────
_DEFAULT_CONCURRENT_PARTS = 1
_MAX_CONCURRENT_PARTS = 10
_PART_UPLOAD_TIMEOUT = 300.0 # 5 minutes per COS PUT
_PART_UPLOAD_MAX_RETRIES = 2
_PART_FINISH_RETRY_INTERVAL = 1.0
_PART_FINISH_DEFAULT_TIMEOUT = 120.0
_PART_FINISH_MAX_TIMEOUT = 600.0
_COMPLETE_UPLOAD_MAX_RETRIES = 2
_COMPLETE_UPLOAD_BASE_DELAY = 2.0
# First 10,002,432 bytes used for the ``md5_10m`` hash (per QQ API spec).
_MD5_10M_SIZE = 10_002_432
# ── Exceptions ───────────────────────────────────────────────────────
class UploadDailyLimitExceededError(Exception):
"""Raised when ``upload_prepare`` returns biz_code 40093002.
The daily cumulative upload quota for this bot has been reached. Callers
should surface :attr:`file_name` + :attr:`file_size_human` so the model
can compose a helpful reply.
"""
def __init__(self, file_name: str, file_size: int, message: str = "") -> None:
self.file_name = file_name
self.file_size = file_size
super().__init__(
message or f"Daily upload limit exceeded for {file_name!r}"
)
@property
def file_size_human(self) -> str:
return format_size(self.file_size)
class UploadFileTooLargeError(Exception):
"""Raised when a file exceeds the platform per-file size limit."""
def __init__(
self,
file_name: str,
file_size: int,
limit_bytes: int = 0,
message: str = "",
) -> None:
self.file_name = file_name
self.file_size = file_size
self.limit_bytes = limit_bytes
limit_str = f" ({format_size(limit_bytes)})" if limit_bytes else ""
super().__init__(
message
or (
f"File {file_name!r} ({format_size(file_size)}) "
f"exceeds platform limit{limit_str}"
)
)
@property
def file_size_human(self) -> str:
return format_size(self.file_size)
@property
def limit_human(self) -> str:
return format_size(self.limit_bytes) if self.limit_bytes else "unknown"
# ── Progress tracking ────────────────────────────────────────────────
@dataclass
class _UploadProgress:
total_parts: int = 0
total_bytes: int = 0
completed_parts: int = 0
uploaded_bytes: int = 0
# ── Prepare-response shape ───────────────────────────────────────────
@dataclass
class _PreparePart:
index: int
presigned_url: str
block_size: int = 0
@dataclass
class _PrepareResult:
upload_id: str
block_size: int
parts: List[_PreparePart]
concurrency: int = _DEFAULT_CONCURRENT_PARTS
retry_timeout: float = 0.0
def _parse_prepare_response(raw: Dict[str, Any]) -> _PrepareResult:
"""Parse the upload_prepare API response into a normalized shape.
The API may return the response directly or wrapped in ``data``.
"""
src = raw.get("data") if isinstance(raw.get("data"), dict) else raw
upload_id = str(src.get("upload_id", ""))
if not upload_id:
raise ValueError(
f"upload_prepare response missing upload_id: {str(raw)[:200]}"
)
block_size = int(src.get("block_size", 0))
raw_parts = src.get("parts") or src.get("part_list") or []
if not isinstance(raw_parts, list) or not raw_parts:
raise ValueError(
f"upload_prepare response missing parts: {str(raw)[:200]}"
)
parts: List[_PreparePart] = []
for p in raw_parts:
if not isinstance(p, dict):
continue
parts.append(
_PreparePart(
index=int(p.get("part_index") or p.get("index") or 0),
presigned_url=str(
p.get("presigned_url") or p.get("url") or ""
),
block_size=int(p.get("block_size", 0)),
)
)
return _PrepareResult(
upload_id=upload_id,
block_size=block_size,
parts=parts,
concurrency=int(src.get("concurrency", _DEFAULT_CONCURRENT_PARTS)) or _DEFAULT_CONCURRENT_PARTS,
retry_timeout=float(src.get("retry_timeout", 0.0) or 0.0),
)
# ── Chunked upload driver ────────────────────────────────────────────
ApiRequestFn = Callable[..., Awaitable[Dict[str, Any]]]
"""Signature of the adapter's ``_api_request`` callable.
We pass the bound method in rather than importing the adapter, to avoid
circular imports and keep this module testable in isolation.
"""
class ChunkedUploader:
"""Run the prepare → PUT parts → complete sequence.
:param api_request: Bound ``_api_request(method, path, body=..., timeout=...)``
coroutine from the adapter. Must raise ``RuntimeError`` with the biz_code
embedded in the message on API errors.
:param http_put: Coroutine ``(url, data, headers, timeout) -> response`` for
COS part uploads. Typically wraps ``httpx.AsyncClient.put``.
:param log_tag: Log prefix.
"""
def __init__(
self,
api_request: ApiRequestFn,
http_put: Callable[..., Awaitable[Any]],
log_tag: str = "QQBot",
) -> None:
self._api_request = api_request
self._http_put = http_put
self._log_tag = log_tag
async def upload(
self,
chat_type: str,
target_id: str,
file_path: str,
file_type: int,
file_name: str,
) -> Dict[str, Any]:
"""Run the full chunked upload and return the ``complete_upload`` response.
:param chat_type: ``'c2c'`` or ``'group'``.
:param target_id: User or group openid.
:param file_path: Absolute path to a local file.
:param file_type: ``MEDIA_TYPE_*`` constant.
:param file_name: Original filename (for upload_prepare).
:returns: The raw response dict from ``complete_upload`` — contains
``file_info`` that the caller uses in a RichMedia message body.
:raises UploadDailyLimitExceededError: On biz_code 40093002.
:raises UploadFileTooLargeError: When the file exceeds the platform limit.
:raises RuntimeError: On other API or I/O failures.
"""
if chat_type not in {"c2c", "group"}:
raise ValueError(
f"ChunkedUploader: unsupported chat_type {chat_type!r}"
)
path = Path(file_path)
file_size = path.stat().st_size
logger.info(
"[%s] Chunked upload start: file=%s size=%s type=%d",
self._log_tag, file_name, format_size(file_size), file_type,
)
# Step 1: compute hashes (blocking I/O → executor).
hashes = await asyncio.get_running_loop().run_in_executor(
None, _compute_file_hashes, file_path, file_size
)
# Step 2: upload_prepare.
prepare = await self._prepare(
chat_type, target_id, file_type, file_name, file_size, hashes
)
max_concurrent = min(prepare.concurrency, _MAX_CONCURRENT_PARTS)
retry_timeout = min(
prepare.retry_timeout if prepare.retry_timeout > 0 else _PART_FINISH_DEFAULT_TIMEOUT,
_PART_FINISH_MAX_TIMEOUT,
)
logger.info(
"[%s] Prepared: upload_id=%s block_size=%s parts=%d concurrency=%d",
self._log_tag, prepare.upload_id, format_size(prepare.block_size),
len(prepare.parts), max_concurrent,
)
progress = _UploadProgress(
total_parts=len(prepare.parts),
total_bytes=file_size,
)
# Step 3: PUT each part + notify.
tasks: List[Callable[[], Awaitable[None]]] = [
functools.partial(
self._upload_one_part,
chat_type=chat_type,
target_id=target_id,
file_path=file_path,
file_size=file_size,
upload_id=prepare.upload_id,
rsp_block_size=prepare.block_size,
part=part,
retry_timeout=retry_timeout,
progress=progress,
)
for part in prepare.parts
]
await _run_with_concurrency(tasks, max_concurrent)
logger.info(
"[%s] All %d parts uploaded, completing…",
self._log_tag, len(prepare.parts),
)
# Step 4: complete_upload (retry on transient errors).
return await self._complete(chat_type, target_id, prepare.upload_id)
# ──────────────────────────────────────────────────────────────────
# Step 1 — upload_prepare
# ──────────────────────────────────────────────────────────────────
async def _prepare(
self,
chat_type: str,
target_id: str,
file_type: int,
file_name: str,
file_size: int,
hashes: Dict[str, str],
) -> _PrepareResult:
base = "/v2/users" if chat_type == "c2c" else "/v2/groups"
path = f"{base}/{target_id}/upload_prepare"
body = {
"file_type": file_type,
"file_name": file_name,
"file_size": file_size,
"md5": hashes["md5"],
"sha1": hashes["sha1"],
"md5_10m": hashes["md5_10m"],
}
try:
raw = await self._api_request(
"POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT
)
except RuntimeError as exc:
err_msg = str(exc)
if f"{_BIZ_CODE_DAILY_LIMIT}" in err_msg:
raise UploadDailyLimitExceededError(
file_name, file_size, err_msg
) from exc
raise
return _parse_prepare_response(raw)
# ──────────────────────────────────────────────────────────────────
# Step 2 — PUT one part + part_finish
# ──────────────────────────────────────────────────────────────────
async def _upload_one_part(
self,
chat_type: str,
target_id: str,
file_path: str,
file_size: int,
upload_id: str,
rsp_block_size: int,
part: _PreparePart,
retry_timeout: float,
progress: _UploadProgress,
) -> None:
"""PUT one part to COS, then call ``upload_part_finish``."""
part_index = part.index
# Per-part block_size wins; fall back to the response-level value.
actual_block_size = part.block_size if part.block_size > 0 else rsp_block_size
offset = (part_index - 1) * rsp_block_size
length = min(actual_block_size, file_size - offset)
# Read this slice of the file (blocking → executor).
data = await asyncio.get_running_loop().run_in_executor(
None, _read_file_chunk, file_path, offset, length
)
md5_hex = hashlib.md5(data).hexdigest()
logger.debug(
"[%s] Part %d/%d: uploading %s (offset=%d md5=%s)",
self._log_tag, part_index, progress.total_parts,
format_size(length), offset, md5_hex,
)
await self._put_to_presigned_url(
part.presigned_url, data, part_index, progress.total_parts
)
await self._part_finish_with_retry(
chat_type, target_id, upload_id,
part_index, length, md5_hex, retry_timeout,
)
progress.completed_parts += 1
progress.uploaded_bytes += length
logger.debug(
"[%s] Part %d/%d done (%d/%d total)",
self._log_tag, part_index, progress.total_parts,
progress.completed_parts, progress.total_parts,
)
async def _put_to_presigned_url(
self,
url: str,
data: bytes,
part_index: int,
total_parts: int,
) -> None:
"""PUT part data to a pre-signed COS URL with retry."""
last_exc: Optional[Exception] = None
for attempt in range(_PART_UPLOAD_MAX_RETRIES + 1):
try:
resp = await asyncio.wait_for(
self._http_put(
url,
data=data,
headers={"Content-Length": str(len(data))},
),
timeout=_PART_UPLOAD_TIMEOUT,
)
# Caller's http_put is expected to return an httpx-like response.
status = getattr(resp, "status_code", 0)
if 200 <= status < 300:
logger.debug(
"[%s] PUT part %d/%d: %d OK",
self._log_tag, part_index, total_parts, status,
)
return
body_preview = ""
try:
body_preview = getattr(resp, "text", "")[:200]
except Exception: # pragma: no cover — defensive
pass
raise RuntimeError(
f"COS PUT returned {status}: {body_preview}"
)
except Exception as exc:
last_exc = exc
if attempt < _PART_UPLOAD_MAX_RETRIES:
delay = 1.0 * (2 ** attempt)
logger.warning(
"[%s] PUT part %d/%d attempt %d failed, retry in %.1fs: %s",
self._log_tag, part_index, total_parts,
attempt + 1, delay, exc,
)
await asyncio.sleep(delay)
raise RuntimeError(
f"Part {part_index}/{total_parts} upload failed after "
f"{_PART_UPLOAD_MAX_RETRIES + 1} attempts: {last_exc}"
)
async def _part_finish_with_retry(
self,
chat_type: str,
target_id: str,
upload_id: str,
part_index: int,
block_size: int,
md5: str,
retry_timeout: float,
) -> None:
"""Call ``upload_part_finish``, retrying on biz_code 40093001."""
base = "/v2/users" if chat_type == "c2c" else "/v2/groups"
path = f"{base}/{target_id}/upload_part_finish"
body = {
"upload_id": upload_id,
"part_index": part_index,
"block_size": block_size,
"md5": md5,
}
loop = asyncio.get_running_loop()
start = loop.time()
attempt = 0
while True:
try:
await self._api_request(
"POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT
)
return
except RuntimeError as exc:
err_msg = str(exc)
if f"{_BIZ_CODE_PART_RETRYABLE}" not in err_msg:
raise
elapsed = loop.time() - start
if elapsed >= retry_timeout:
raise RuntimeError(
f"upload_part_finish persistent retry timed out "
f"after {retry_timeout:.0f}s ({attempt} retries): {exc}"
) from exc
attempt += 1
logger.debug(
"[%s] part_finish retryable error, attempt %d, "
"elapsed=%.1fs: %s",
self._log_tag, attempt, elapsed, exc,
)
await asyncio.sleep(_PART_FINISH_RETRY_INTERVAL)
# ──────────────────────────────────────────────────────────────────
# Step 3 — complete_upload
# ──────────────────────────────────────────────────────────────────
async def _complete(
self,
chat_type: str,
target_id: str,
upload_id: str,
) -> Dict[str, Any]:
"""Call ``complete_upload`` with retry.
This reuses the ``/files`` endpoint (same as the simple URL-based upload)
but signals the chunked-completion path by sending only ``upload_id``.
"""
base = "/v2/users" if chat_type == "c2c" else "/v2/groups"
path = f"{base}/{target_id}/files"
body = {"upload_id": upload_id}
last_exc: Optional[Exception] = None
for attempt in range(_COMPLETE_UPLOAD_MAX_RETRIES + 1):
try:
return await self._api_request(
"POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT
)
except Exception as exc:
last_exc = exc
if attempt < _COMPLETE_UPLOAD_MAX_RETRIES:
delay = _COMPLETE_UPLOAD_BASE_DELAY * (2 ** attempt)
logger.warning(
"[%s] complete_upload attempt %d failed, "
"retry in %.1fs: %s",
self._log_tag, attempt + 1, delay, exc,
)
await asyncio.sleep(delay)
raise RuntimeError(
f"complete_upload failed after "
f"{_COMPLETE_UPLOAD_MAX_RETRIES + 1} attempts: {last_exc}"
)
# ── Helpers (module-level for testability) ───────────────────────────
def format_size(size_bytes: int) -> str:
"""Return a human-readable file size string (e.g. ``'12.3 MB'``)."""
size = float(size_bytes)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TB"
def _read_file_chunk(file_path: str, offset: int, length: int) -> bytes:
"""Read *length* bytes from *file_path* starting at *offset*.
:raises IOError: If fewer bytes were read than expected (truncated file).
"""
with open(file_path, "rb") as fh:
fh.seek(offset)
data = fh.read(length)
if len(data) != length:
raise IOError(
f"Short read from {file_path}: expected {length} bytes at "
f"offset {offset}, got {len(data)} (file may be truncated)"
)
return data
def _compute_file_hashes(file_path: str, file_size: int) -> Dict[str, str]:
"""Compute md5, sha1, and md5_10m in a single pass."""
md5 = hashlib.md5()
sha1 = hashlib.sha1()
md5_10m = hashlib.md5()
need_10m = file_size > _MD5_10M_SIZE
bytes_read = 0
with open(file_path, "rb") as fh:
while True:
chunk = fh.read(65536)
if not chunk:
break
md5.update(chunk)
sha1.update(chunk)
if need_10m:
remaining = _MD5_10M_SIZE - bytes_read
if remaining > 0:
md5_10m.update(chunk[:remaining])
bytes_read += len(chunk)
full_md5 = md5.hexdigest()
return {
"md5": full_md5,
"sha1": sha1.hexdigest(),
# For small files the "10m" hash is just the full md5.
"md5_10m": md5_10m.hexdigest() if need_10m else full_md5,
}
async def _run_with_concurrency(
tasks: List[Callable[[], Awaitable[None]]],
concurrency: int,
) -> None:
"""Run a list of thunks with a bounded number in flight at once."""
concurrency = max(concurrency, 1)
sem = asyncio.Semaphore(concurrency)
async def _wrap(thunk: Callable[[], Awaitable[None]]) -> None:
async with sem:
await thunk()
await asyncio.gather(*(_wrap(t) for t in tasks))
+74
View File
@@ -0,0 +1,74 @@
"""QQBot package-level constants shared across adapter, onboard, and other modules."""
from __future__ import annotations
import os
# ---------------------------------------------------------------------------
# QQBot adapter version — bump on functional changes to the adapter package.
# ---------------------------------------------------------------------------
QQBOT_VERSION = "1.1.0"
# ---------------------------------------------------------------------------
# API endpoints
# ---------------------------------------------------------------------------
# The portal domain is configurable via QQ_API_HOST for corporate proxies
# or test environments. Default: q.qq.com (production).
PORTAL_HOST = os.getenv("QQ_PORTAL_HOST", "q.qq.com")
API_BASE = "https://api.sgroup.qq.com"
TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"
GATEWAY_URL_PATH = "/gateway"
# QR-code onboard endpoints (on the portal host)
ONBOARD_CREATE_PATH = "/lite/create_bind_task"
ONBOARD_POLL_PATH = "/lite/poll_bind_result"
QR_URL_TEMPLATE = (
"https://q.qq.com/qqbot/openclaw/connect.html"
"?task_id={task_id}&_wv=2&source=hermes"
)
# ---------------------------------------------------------------------------
# Timeouts & retry
# ---------------------------------------------------------------------------
DEFAULT_API_TIMEOUT = 30.0
FILE_UPLOAD_TIMEOUT = 120.0
CONNECT_TIMEOUT_SECONDS = 20.0
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
MAX_RECONNECT_ATTEMPTS = 100
RATE_LIMIT_DELAY = 60 # seconds
QUICK_DISCONNECT_THRESHOLD = 5.0 # seconds
MAX_QUICK_DISCONNECT_COUNT = 3
ONBOARD_POLL_INTERVAL = 2.0 # seconds between poll_bind_result calls
ONBOARD_API_TIMEOUT = 10.0
# ---------------------------------------------------------------------------
# Message limits
# ---------------------------------------------------------------------------
MAX_MESSAGE_LENGTH = 4000
DEDUP_WINDOW_SECONDS = 300
DEDUP_MAX_SIZE = 1000
# ---------------------------------------------------------------------------
# QQ Bot message types
# ---------------------------------------------------------------------------
MSG_TYPE_TEXT = 0
MSG_TYPE_MARKDOWN = 2
MSG_TYPE_MEDIA = 7
MSG_TYPE_INPUT_NOTIFY = 6
# ---------------------------------------------------------------------------
# QQ Bot file media types
# ---------------------------------------------------------------------------
MEDIA_TYPE_IMAGE = 1
MEDIA_TYPE_VIDEO = 2
MEDIA_TYPE_VOICE = 3
MEDIA_TYPE_FILE = 4
+45
View File
@@ -0,0 +1,45 @@
"""AES-256-GCM utilities for QQBot scan-to-configure credential decryption."""
from __future__ import annotations
import base64
import os
def generate_bind_key() -> str:
"""Generate a 256-bit random AES key and return it as base64.
The key is passed to ``create_bind_task`` so the server can encrypt
the bot's *client_secret* before returning it. Only this CLI holds
the key, ensuring the secret never travels in plaintext.
"""
return base64.b64encode(os.urandom(32)).decode()
def decrypt_secret(encrypted_base64: str, key_base64: str) -> str:
"""Decrypt a base64-encoded AES-256-GCM ciphertext.
Ciphertext layout (after base64-decoding)::
IV (12 bytes) ‖ ciphertext (N bytes) ‖ AuthTag (16 bytes)
Args:
encrypted_base64: The ``bot_encrypt_secret`` value from
``poll_bind_result``.
key_base64: The base64 AES key generated by
:func:`generate_bind_key`.
Returns:
The decrypted *client_secret* as a UTF-8 string.
"""
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = base64.b64decode(key_base64)
raw = base64.b64decode(encrypted_base64)
iv = raw[:12]
ciphertext_with_tag = raw[12:] # AESGCM expects ciphertext + tag concatenated
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(iv, ciphertext_with_tag, None)
return plaintext.decode("utf-8")
+461
View File
@@ -0,0 +1,461 @@
"""QQ Bot inline keyboards + approval / update-prompt senders.
QQ Bot v2 supports attaching inline keyboards to outbound messages. When a
user clicks a button, the platform dispatches an ``INTERACTION_CREATE``
gateway event containing the button's ``data`` payload. The bot must ACK the
interaction promptly via ``PUT /interactions/{id}`` or the user sees an
error indicator on the button.
This module provides:
- :class:`InlineKeyboard` + button dataclasses — serialized into the
``keyboard`` field of the outbound message body.
- :func:`build_approval_keyboard` — 3-button ✅ once / ⭐ always / ❌ deny
keyboard for tool-approval flows.
- :func:`build_update_prompt_keyboard` — Yes/No keyboard for update confirms.
- :func:`parse_approval_button_data` / :func:`parse_update_prompt_button_data`
— decode the ``button_data`` payload from ``INTERACTION_CREATE``.
- :class:`ApprovalRequest` + :class:`ApprovalSender` — high-level helper that
builds an approval message with keyboard and posts it to a c2c / group chat.
``button_data`` formats::
approve:<session_key>:<decision> # decision = allow-once|allow-always|deny
update_prompt:<answer> # answer = y|n
Ported from WideLee's qqbot-agent-sdk v1.2.2 (``approval.py`` + ``dto.py``
keyboard types). Authorship preserved via Co-authored-by.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
# ── button_data prefixes + patterns ──────────────────────────────────
APPROVAL_BUTTON_PREFIX = "approve:"
UPDATE_PROMPT_PREFIX = "update_prompt:"
# Pattern: approve:<session_key>:<decision>
# session_key may itself contain colons (e.g. agent:main:qqbot:c2c:OPENID),
# so the session_key group is greedy but trails the decision.
_APPROVAL_DATA_RE = re.compile(
r"^approve:(.+):(allow-once|allow-always|deny)$"
)
# Pattern: update_prompt:y | update_prompt:n
_UPDATE_PROMPT_RE = re.compile(r"^update_prompt:(y|n)$")
# ── Keyboard dataclasses ─────────────────────────────────────────────
@dataclass
class KeyboardButtonPermission:
"""Button permission metadata. ``type=2`` means all users can click."""
type: int = 2
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type}
@dataclass
class KeyboardButtonAction:
"""What happens when the button is clicked.
:param type: ``1`` (Callback — triggers ``INTERACTION_CREATE``) or
``2`` (Link — opens a URL).
:param data: Payload delivered in ``data.resolved.button_data`` when
``type=1``.
:param permission: :class:`KeyboardButtonPermission`.
:param click_limit: Max clicks per user (``1`` = single-use).
"""
type: int
data: str
permission: KeyboardButtonPermission = field(
default_factory=KeyboardButtonPermission
)
click_limit: int = 1
def to_dict(self) -> Dict[str, Any]:
return {
"type": self.type,
"data": self.data,
"permission": self.permission.to_dict(),
"click_limit": self.click_limit,
}
@dataclass
class KeyboardButtonRenderData:
"""Visual rendering of a button.
:param label: Pre-click label.
:param visited_label: Post-click label (button stays greyed in place).
:param style: ``0`` = grey, ``1`` = blue.
"""
label: str
visited_label: str
style: int = 1
def to_dict(self) -> Dict[str, Any]:
return {
"label": self.label,
"visited_label": self.visited_label,
"style": self.style,
}
@dataclass
class KeyboardButton:
"""One button in a keyboard.
:param group_id: Buttons sharing a ``group_id`` are mutually exclusive —
clicking one greys the rest.
"""
id: str
render_data: KeyboardButtonRenderData
action: KeyboardButtonAction
group_id: str = "default"
def to_dict(self) -> Dict[str, Any]:
return {
"id": self.id,
"render_data": self.render_data.to_dict(),
"action": self.action.to_dict(),
"group_id": self.group_id,
}
@dataclass
class KeyboardRow:
buttons: List[KeyboardButton] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {"buttons": [b.to_dict() for b in self.buttons]}
@dataclass
class KeyboardContent:
rows: List[KeyboardRow] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {"rows": [r.to_dict() for r in self.rows]}
@dataclass
class InlineKeyboard:
"""Top-level keyboard payload — goes into ``MessageToCreate.keyboard``."""
content: KeyboardContent = field(default_factory=KeyboardContent)
def to_dict(self) -> Dict[str, Any]:
return {"content": self.content.to_dict()}
# ── INTERACTION_CREATE parsing ───────────────────────────────────────
def parse_approval_button_data(button_data: str) -> Optional[tuple[str, str]]:
"""Parse approval ``button_data`` into ``(session_key, decision)``.
:param button_data: Raw ``data.resolved.button_data`` from
``INTERACTION_CREATE``.
:returns: ``(session_key, decision)`` or ``None`` if not an approval button.
"""
m = _APPROVAL_DATA_RE.match(button_data or "")
if not m:
return None
return m.group(1), m.group(2)
def parse_update_prompt_button_data(button_data: str) -> Optional[str]:
"""Parse update-prompt ``button_data`` into ``'y'`` or ``'n'``."""
m = _UPDATE_PROMPT_RE.match(button_data or "")
if not m:
return None
return m.group(1)
# ── Keyboard builders ────────────────────────────────────────────────
def _make_callback_button(
btn_id: str,
label: str,
visited_label: str,
data: str,
style: int,
group_id: str,
) -> KeyboardButton:
return KeyboardButton(
id=btn_id,
render_data=KeyboardButtonRenderData(
label=label,
visited_label=visited_label,
style=style,
),
action=KeyboardButtonAction(type=1, data=data),
group_id=group_id,
)
def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) -> InlineKeyboard:
"""Build the approval keyboard, hiding persistent scope when unavailable.
Layout: ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` — all three share
``group_id='approval'`` so clicking one greys out the rest.
:param session_key: Embedded into ``button_data`` so the decision
routes back to the right pending approval.
"""
buttons = [
_make_callback_button(
btn_id="allow", label="✅ 允许一次", visited_label="已允许",
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-once",
style=1, group_id="approval",
)
]
if allow_permanent:
buttons.append(_make_callback_button(
btn_id="always", label="⭐ 始终允许", visited_label="已始终允许",
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:allow-always",
style=1, group_id="approval",
))
buttons.append(_make_callback_button(
btn_id="deny", label="❌ 拒绝", visited_label="已拒绝",
data=f"{APPROVAL_BUTTON_PREFIX}{session_key}:deny",
style=0, group_id="approval",
))
return InlineKeyboard(content=KeyboardContent(rows=[KeyboardRow(buttons=buttons)]))
def build_update_prompt_keyboard() -> InlineKeyboard:
"""Build a Yes/No keyboard for update confirmation prompts."""
return InlineKeyboard(
content=KeyboardContent(
rows=[
KeyboardRow(buttons=[
_make_callback_button(
btn_id="yes",
label="✓ 确认",
visited_label="已确认",
data=f"{UPDATE_PROMPT_PREFIX}y",
style=1,
group_id="update_prompt",
),
_make_callback_button(
btn_id="no",
label="✗ 取消",
visited_label="已取消",
data=f"{UPDATE_PROMPT_PREFIX}n",
style=0,
group_id="update_prompt",
),
]),
]
)
)
# ── ApprovalRequest + text builder ───────────────────────────────────
@dataclass
class ApprovalRequest:
"""Structured approval-request display data.
:param session_key: Routes the decision back to the waiting caller.
:param title: Short title at the top.
:param description: Optional longer description.
:param command_preview: Command text (exec approvals).
:param cwd: Working directory (exec approvals).
:param tool_name: Tool name (plugin approvals).
:param severity: ``'critical' | 'info' | ''``.
:param timeout_sec: Seconds until the approval expires.
"""
session_key: str
title: str
description: str = ""
command_preview: str = ""
cwd: str = ""
tool_name: str = ""
severity: str = ""
timeout_sec: int = 120
allow_permanent: bool = True
def build_approval_text(req: ApprovalRequest) -> str:
"""Render an :class:`ApprovalRequest` into the message body (markdown)."""
if req.command_preview or req.cwd:
return _build_exec_text(req)
return _build_plugin_text(req)
def _build_exec_text(req: ApprovalRequest) -> str:
lines: List[str] = ["🔐 **命令执行审批**", ""]
if req.command_preview:
preview = req.command_preview[:300]
lines.append(f"```\n{preview}\n```")
if req.cwd:
lines.append(f"📁 目录: {req.cwd}")
if req.title and req.title != req.command_preview:
lines.append(f"📋 {req.title}")
if req.description:
lines.append(f"📝 {req.description}")
lines.append("")
lines.append(f"⏱️ 超时: {req.timeout_sec}")
return "\n".join(lines)
def _build_plugin_text(req: ApprovalRequest) -> str:
icon = (
"🔴" if req.severity == "critical"
else "🔵" if req.severity == "info"
else "🟡"
)
lines: List[str] = [f"{icon} **审批请求**", ""]
lines.append(f"📋 {req.title}")
if req.description:
lines.append(f"📝 {req.description}")
if req.tool_name:
lines.append(f"🔧 工具: {req.tool_name}")
lines.append("")
lines.append(f"⏱️ 超时: {req.timeout_sec}")
return "\n".join(lines)
# ── ApprovalSender ───────────────────────────────────────────────────
PostMessageFn = Callable[..., Awaitable[Dict[str, Any]]]
"""Signature of an async POST to ``/v2/{users|groups}/{id}/messages``.
Implementations accept a body dict and return the raw API response.
"""
class ApprovalSender:
"""Send an approval-request message with an inline keyboard.
Decoupled from the adapter via callables so it can be unit-tested in
isolation. Pass the adapter's ``_send_message_with_keyboard`` helper
(or any equivalent) as ``post_message``.
"""
def __init__(
self,
post_c2c: PostMessageFn,
post_group: PostMessageFn,
log_tag: str = "QQBot",
) -> None:
self._post_c2c = post_c2c
self._post_group = post_group
self._log_tag = log_tag
async def send(
self,
chat_type: str,
chat_id: str,
req: ApprovalRequest,
msg_id: Optional[str] = None,
) -> bool:
"""Send an approval message to *chat_id*.
:param chat_type: ``'c2c'`` or ``'group'``.
:param chat_id: User openid or group openid.
:param req: :class:`ApprovalRequest`.
:param msg_id: Reply-to message id (required for passive messages).
:returns: ``True`` on success, ``False`` on failure.
"""
text = build_approval_text(req)
keyboard = build_approval_keyboard(req.session_key)
logger.info(
"[%s] Sending approval request to %s:%s (session=%.20s…)",
self._log_tag, chat_type, chat_id, req.session_key,
)
try:
if chat_type == "c2c":
await self._post_c2c(chat_id, text, msg_id, keyboard)
elif chat_type == "group":
await self._post_group(chat_id, text, msg_id, keyboard)
else:
logger.warning(
"[%s] Approval: unsupported chat_type %r",
self._log_tag, chat_type,
)
return False
logger.info(
"[%s] Approval message sent to %s:%s",
self._log_tag, chat_type, chat_id,
)
return True
except Exception as exc:
logger.error(
"[%s] Failed to send approval message to %s:%s: %s",
self._log_tag, chat_type, chat_id, exc,
)
return False
# ── INTERACTION_CREATE event shape ───────────────────────────────────
@dataclass
class InteractionEvent:
"""Parsed ``INTERACTION_CREATE`` event payload.
See https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
"""
id: str = ""
"""Interaction event id — required for the ``PUT /interactions/{id}`` ACK."""
type: int = 0
"""Event type code (``11`` = message button)."""
chat_type: int = 0
"""``0`` = guild, ``1`` = group, ``2`` = c2c."""
scene: str = ""
"""``'guild'`` | ``'group'`` | ``'c2c'`` — human-readable scene."""
group_openid: str = ""
group_member_openid: str = ""
user_openid: str = ""
channel_id: str = ""
guild_id: str = ""
button_data: str = ""
button_id: str = ""
resolver_user_id: str = ""
@property
def operator_openid(self) -> str:
"""Best available operator openid (group → member; c2c → user)."""
return (
self.group_member_openid
or self.user_openid
or self.resolver_user_id
)
def parse_interaction_event(raw: Dict[str, Any]) -> InteractionEvent:
"""Parse a raw ``INTERACTION_CREATE`` dispatch payload (``d``)."""
data_raw = raw.get("data") or {}
resolved = data_raw.get("resolved") or {}
scene_code = int(raw.get("chat_type", 0) or 0)
scene = {0: "guild", 1: "group", 2: "c2c"}.get(scene_code, "")
return InteractionEvent(
id=str(raw.get("id", "")),
type=int(data_raw.get("type", 0) or 0),
chat_type=scene_code,
scene=scene,
group_openid=str(raw.get("group_openid", "")),
group_member_openid=str(raw.get("group_member_openid", "")),
user_openid=str(raw.get("user_openid", "")),
channel_id=str(raw.get("channel_id", "")),
guild_id=str(raw.get("guild_id", "")),
button_data=str(resolved.get("button_data", "")),
button_id=str(resolved.get("button_id", "")),
resolver_user_id=str(resolved.get("user_id", "")),
)
+220
View File
@@ -0,0 +1,220 @@
"""
QQBot scan-to-configure (QR code onboard) module.
Mirrors the Feishu onboarding pattern: synchronous HTTP + a single public
entry-point ``qr_register()`` that handles the full flow (create task →
display QR code → poll → decrypt credentials).
Calls the ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result`` APIs to
generate a QR-code URL and poll for scan completion. On success the caller
receives the bot's *app_id*, *client_secret* (decrypted locally), and the
scanner's *user_openid* — enough to fully configure the QQBot gateway.
Reference: https://bot.q.qq.com/wiki/develop/api-v2/
"""
from __future__ import annotations
import logging
import time
from enum import IntEnum
from typing import Optional, Tuple
from urllib.parse import quote
from .constants import (
ONBOARD_API_TIMEOUT,
ONBOARD_CREATE_PATH,
ONBOARD_POLL_INTERVAL,
ONBOARD_POLL_PATH,
PORTAL_HOST,
QR_URL_TEMPLATE,
)
from .crypto import decrypt_secret, generate_bind_key
from .utils import get_api_headers
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Bind status
# ---------------------------------------------------------------------------
class BindStatus(IntEnum):
"""Status codes returned by ``_poll_bind_result``."""
NONE = 0
PENDING = 1
COMPLETED = 2
EXPIRED = 3
# ---------------------------------------------------------------------------
# QR rendering
# ---------------------------------------------------------------------------
try:
import qrcode as _qrcode_mod
except (ImportError, TypeError):
_qrcode_mod = None # type: ignore[assignment]
def _render_qr(url: str) -> bool:
"""Try to render a QR code in the terminal. Returns True if successful."""
if _qrcode_mod is None:
return False
try:
qr = _qrcode_mod.QRCode(
error_correction=_qrcode_mod.constants.ERROR_CORRECT_M,
border=2,
)
qr.add_data(url)
qr.make(fit=True)
qr.print_ascii(invert=True)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# Synchronous HTTP helpers (mirrors Feishu _post_registration pattern)
# ---------------------------------------------------------------------------
def _create_bind_task(timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[str, str]:
"""Create a bind task and return *(task_id, aes_key_base64)*.
Raises:
RuntimeError: If the API returns a non-zero ``retcode``.
"""
import httpx
url = f"https://{PORTAL_HOST}{ONBOARD_CREATE_PATH}"
key = generate_bind_key()
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
resp = client.post(url, json={"key": key}, headers=get_api_headers())
resp.raise_for_status()
data = resp.json()
if data.get("retcode") != 0:
raise RuntimeError(data.get("msg", "create_bind_task failed"))
task_id = (data.get("data") or {}).get("task_id")
if not task_id:
raise RuntimeError("create_bind_task: missing task_id in response")
logger.debug("create_bind_task ok: task_id=%s", task_id)
return task_id, key
def _poll_bind_result(
task_id: str,
timeout: float = ONBOARD_API_TIMEOUT,
) -> Tuple[BindStatus, str, str, str]:
"""Poll the bind result for *task_id*.
Returns:
A 4-tuple of ``(status, bot_appid, bot_encrypt_secret, user_openid)``.
Raises:
RuntimeError: If the API returns a non-zero ``retcode``.
"""
import httpx
url = f"https://{PORTAL_HOST}{ONBOARD_POLL_PATH}"
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
resp = client.post(url, json={"task_id": task_id}, headers=get_api_headers())
resp.raise_for_status()
data = resp.json()
if data.get("retcode") != 0:
raise RuntimeError(data.get("msg", "poll_bind_result failed"))
d = data.get("data", {})
return (
BindStatus(d.get("status", 0)),
str(d.get("bot_appid", "")),
d.get("bot_encrypt_secret", ""),
d.get("user_openid", ""),
)
def build_connect_url(task_id: str) -> str:
"""Build the QR-code target URL for a given *task_id*."""
return QR_URL_TEMPLATE.format(task_id=quote(task_id))
# ---------------------------------------------------------------------------
# Public entry-point
# ---------------------------------------------------------------------------
_MAX_REFRESHES = 3
def qr_register(timeout_seconds: int = 600) -> Optional[dict]:
"""Run the QQBot scan-to-configure QR registration flow.
Mirrors ``feishu.qr_register()``: handles create → display → poll →
decrypt in one call. Unexpected errors propagate to the caller.
:returns:
``{"app_id": ..., "client_secret": ..., "user_openid": ...}`` on
success, or ``None`` on failure / expiry / cancellation.
"""
deadline = time.monotonic() + timeout_seconds
for refresh_count in range(_MAX_REFRESHES + 1):
# ── Create bind task ──
try:
task_id, aes_key = _create_bind_task()
except Exception as exc:
logger.warning("[QQBot onboard] Failed to create bind task: %s", exc)
return None
url = build_connect_url(task_id)
# ── Display QR code + URL ──
print()
if _render_qr(url):
print(f" Scan the QR code above, or open this URL directly:\n {url}")
else:
print(f" Open this URL in QQ on your phone:\n {url}")
print(" Tip: pip install qrcode to display a scannable QR code here")
print()
# ── Poll loop ──
while time.monotonic() < deadline:
try:
status, app_id, encrypted_secret, user_openid = _poll_bind_result(task_id)
except Exception:
time.sleep(ONBOARD_POLL_INTERVAL)
continue
if status == BindStatus.COMPLETED:
client_secret = decrypt_secret(encrypted_secret, aes_key)
print()
print(f" QR scan complete! (App ID: {app_id})")
if user_openid:
print(f" Scanner's OpenID: {user_openid}")
return {
"app_id": app_id,
"client_secret": client_secret,
"user_openid": user_openid,
}
if status == BindStatus.EXPIRED:
if refresh_count >= _MAX_REFRESHES:
logger.warning("[QQBot onboard] QR code expired %d times — giving up", _MAX_REFRESHES)
return None
print(f"\n QR code expired, refreshing... ({refresh_count + 1}/{_MAX_REFRESHES})")
break # next for-loop iteration creates a new task
time.sleep(ONBOARD_POLL_INTERVAL)
else:
# deadline reached without completing
logger.warning("[QQBot onboard] Poll timed out after %ds", timeout_seconds)
return None
return None
+71
View File
@@ -0,0 +1,71 @@
"""QQBot shared utilities — User-Agent, HTTP helpers, config coercion."""
from __future__ import annotations
import platform
import sys
from typing import Any, Dict, List
from .constants import QQBOT_VERSION
# ---------------------------------------------------------------------------
# User-Agent
# ---------------------------------------------------------------------------
def _get_hermes_version() -> str:
"""Return the hermes-agent package version, or 'dev' if unavailable."""
try:
from importlib.metadata import version
return version("hermes-agent")
except Exception:
return "dev"
def build_user_agent() -> str:
"""Build a descriptive User-Agent string.
Format::
QQBotAdapter/<qqbot_version> (Python/<py_version>; <os>; Hermes/<hermes_version>)
Example::
QQBotAdapter/1.0.0 (Python/3.11.15; darwin; Hermes/0.9.0)
"""
py_version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
os_name = platform.system().lower()
hermes_version = _get_hermes_version()
return f"QQBotAdapter/{QQBOT_VERSION} (Python/{py_version}; {os_name}; Hermes/{hermes_version})"
def get_api_headers() -> Dict[str, str]:
"""Return standard HTTP headers for QQBot API requests.
Includes ``Content-Type``, ``Accept``, and a dynamic ``User-Agent``.
``q.qq.com`` requires ``Accept: application/json`` — without it,
the server returns a JavaScript anti-bot challenge page.
"""
return {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": build_user_agent(),
}
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
def coerce_list(value: Any) -> List[str]:
"""Coerce config values into a trimmed string list.
Accepts comma-separated strings, lists, tuples, sets, or single values.
"""
if value is None:
return []
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
if isinstance(value, (list, tuple, set)):
return [str(item).strip() for item in value if str(item).strip()]
return [str(value).strip()] if str(value).strip() else []
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
"""Shared Signal formatting helpers.
Keep markdown → Signal native formatting conversion in one place so both the
live Signal adapter and standalone send paths emit the same bodyRanges.
"""
from __future__ import annotations
import re
def markdown_to_signal(text: str) -> tuple[str, list[str]]:
"""Convert markdown to plain text + Signal textStyles list.
Signal doesn't render markdown. Instead it uses ``bodyRanges`` (exposed by
signal-cli as ``textStyle`` / ``textStyles`` params) with the format
``start:length:STYLE``.
Positions are measured in UTF-16 code units because that's what the Signal
protocol uses.
Supported styles: BOLD, ITALIC, STRIKETHROUGH, MONOSPACE.
"""
def _utf16_len(s: str) -> int:
"""Length of *s* in UTF-16 code units."""
return len(s.encode("utf-16-le")) // 2
def _normalize_bullet_markers(source: str) -> str:
"""Replace Markdown bullet markers with plain Unicode bullets.
Signal does not render Markdown list syntax, so ``- item`` and
``* item`` otherwise arrive as literal Markdown markers. Preserve
fenced code blocks byte-for-byte; list-looking lines inside code are
code, not prose bullets.
"""
parts = re.split(r"(```.*?```)", source, flags=re.DOTALL)
for idx, part in enumerate(parts):
if idx % 2 == 1:
continue
parts[idx] = re.sub(r"(?m)^([ \t]{0,3})[-*+]\s+", r"\1• ", part)
return "".join(parts)
text = re.sub(r"\n{3,}", "\n\n", text)
text = text.strip()
text = _normalize_bullet_markers(text)
styles: list[tuple[int, int, str]] = []
code_block = re.compile(r"```[a-zA-Z0-9_+-]*\n?(.*?)```", re.DOTALL)
while match := code_block.search(text):
inner = match.group(1).rstrip("\n")
start = match.start()
text = text[: match.start()] + inner + text[match.end() :]
styles.append((start, len(inner), "MONOSPACE"))
heading = re.compile(r"^#{1,6}\s+", re.MULTILINE)
new_text = ""
last_end = 0
for match in heading.finditer(text):
new_text += text[last_end : match.start()]
last_end = match.end()
eol = text.find("\n", match.end())
if eol == -1:
eol = len(text)
heading_text = text[match.end() : eol]
start = len(new_text)
new_text += heading_text
styles.append((start, len(heading_text), "BOLD"))
last_end = eol
new_text += text[last_end:]
text = new_text
patterns = [
(re.compile(r"\*\*(.+?)\*\*", re.DOTALL), "BOLD"),
(re.compile(r"__(.+?)__", re.DOTALL), "BOLD"),
(re.compile(r"~~(.+?)~~", re.DOTALL), "STRIKETHROUGH"),
(re.compile(r"`(.+?)`"), "MONOSPACE"),
(re.compile(r"(?<!\*)\*(?!\*| )(.+?)(?<!\*)\*(?!\*)"), "ITALIC"),
(re.compile(r"(?<!\w)_(?!_)(.+?)(?<!_)_(?!\w)"), "ITALIC"),
]
all_matches: list[tuple[int, int, int, int, str]] = []
occupied: list[tuple[int, int]] = []
for pattern, style in patterns:
for match in pattern.finditer(text):
ms, me = match.start(), match.end()
if not any(ms < oe and me > os for os, oe in occupied):
all_matches.append((ms, me, match.start(1), match.end(1), style))
occupied.append((ms, me))
all_matches.sort()
removals: list[tuple[int, int]] = []
for ms, me, g1s, g1e, _ in all_matches:
if g1s > ms:
removals.append((ms, g1s - ms))
if me > g1e:
removals.append((g1e, me - g1e))
removals.sort()
def _adjust(pos: int) -> int:
shift = 0
for remove_pos, remove_len in removals:
if remove_pos < pos:
shift += min(remove_len, pos - remove_pos)
else:
break
return pos - shift
adjusted_prior: list[tuple[int, int, str]] = []
for start, length, style in styles:
new_start = _adjust(start)
new_end = _adjust(start + length)
if new_end > new_start:
adjusted_prior.append((new_start, new_end - new_start, style))
result = ""
last_end = 0
inline_styles: list[tuple[int, int, str]] = []
for ms, me, g1s, g1e, style in all_matches:
result += text[last_end:ms]
pos = len(result)
inner = text[g1s:g1e]
result += inner
inline_styles.append((pos, len(inner), style))
last_end = me
result += text[last_end:]
text = result
styles = adjusted_prior + inline_styles
style_strings: list[str] = []
for cp_start, cp_len, style_type in sorted(styles):
if cp_start < 0 or cp_start + cp_len > len(text):
continue
u16_start = _utf16_len(text[:cp_start])
u16_len = _utf16_len(text[cp_start : cp_start + cp_len])
style_strings.append(f"{u16_start}:{u16_len}:{style_type}")
return text, style_strings
+374
View File
@@ -0,0 +1,374 @@
"""
Signal attachment rate-limit scheduler.
Process-wide token-bucket simulator that mirrors the per-account
attachment rate limit signal-cli/Signal-Server enforce. Producers
(``SignalAdapter.send_multiple_images`` and the ``send_message`` tool's
Signal path) call ``acquire(n)`` before an attachment send; on a 429
they call ``feedback(retry_after, n)`` so the model recalibrates from
the server's authoritative hint.
The scheduler serializes concurrent calls through an ``asyncio.Lock``,
giving FIFO fairness across agent sessions sharing one signal-cli
daemon.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
from typing import Any, Optional
from agent.retry_utils import parse_retry_after_seconds
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SIGNAL_MAX_ATTACHMENTS_PER_MSG = 32 # per-message attachment cap (source: Signal-{Android,Desktop} source code)
SIGNAL_RATE_LIMIT_BUCKET_CAPACITY = 50 # server-side token-bucket capacity for attachments rate limiting
SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER = 4 # fallback token refill interval for signal-cli < v0.14.3
SIGNAL_RATE_LIMIT_MAX_ATTEMPTS = 2 # initial attempt + 1 retry
SIGNAL_BATCH_PACING_NOTICE_THRESHOLD = 10.0 # if estimated waiting time > 10s, notify the user about the delay
SIGNAL_RPC_ERROR_RATELIMIT = -5 # signal-cli (v0.14.3+) JSON-RPC error code for RateLimitException
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class SignalRateLimitError(Exception):
"""
Raised by ``SignalAdapter._rpc`` for rate-limit responses when the
caller has opted in via ``raise_on_rate_limit=True``.
Carries the server-supplied per-token Retry-After (in seconds) on
signal-cli ≥ v0.14.3
``retry_after`` is None when the version doesn't expose it.
"""
def __init__(self, message: str, retry_after: Optional[float] = None) -> None:
super().__init__(message)
self.retry_after = retry_after
class SignalSchedulerError(Exception):
pass
# ---------------------------------------------------------------------------
# Detection helpers — used to fish a 429 out of signal-cli's various error
# shapes (typed code, [429] substring, libsignal-net RetryLaterException
# leaked through AttachmentInvalidException).
# ---------------------------------------------------------------------------
# "Retry after 4 seconds" / "retry after 4 second" — libsignal-net's
# RetryLaterException string form, surfaced when 429s hit during
# attachment upload (signal-cli wraps these as AttachmentInvalidException
# rather than RateLimitException, so the typed path doesn't fire).
_RETRY_AFTER_RE = re.compile(r"Retry after (\d+(?:\.\d+)?)\s*second", re.IGNORECASE)
def _extract_retry_after_seconds(err: Any) -> Optional[float]:
"""Pull the per-token Retry-After window from a signal-cli rate-limit error.
Tries two sources, in order:
1. ``error.data.response.results[*].retryAfterSeconds`` — the
structured field signal-cli ≥ v0.14.3 surfaces for plain
RateLimitException.
2. ``"Retry after N seconds"`` parsed out of the message — covers
libsignal-net's RetryLaterException that gets wrapped as
AttachmentInvalidException during attachment upload, where the
structured field stays null.
Numeric parsing delegates to the shared
:func:`agent.retry_utils.parse_retry_after_seconds` core.
Returns None when neither source yields a value.
"""
msg = ""
if isinstance(err, dict):
data = err.get("data") or {}
response = data.get("response") or {}
results = response.get("results") or []
candidates = [
parse_retry_after_seconds(r.get("retryAfterSeconds")) for r in results
if isinstance(r, dict) and r.get("retryAfterSeconds")
]
candidates = [c for c in candidates if c is not None]
if candidates:
return max(candidates)
msg = str(err.get("message", ""))
else:
msg = str(err)
match = _RETRY_AFTER_RE.search(msg)
return parse_retry_after_seconds(match.group(1)) if match else None
def _is_signal_rate_limit_error(err: Any) -> bool:
"""True if a signal-cli RPC error reflects a rate-limit failure.
Matches three layers:
- typed ``RATELIMIT_ERROR`` code (signal-cli ≥ v0.14.3, plain
RateLimitException)
- legacy ``[429] / RateLimitException`` substrings
- libsignal-net's ``RetryLaterException`` / ``Retry after N seconds``
surfaced inside ``AttachmentInvalidException`` when the rate
limit is hit during attachment upload — signal-cli never re-tags
these as RateLimitException, so substring is the only signal.
"""
if isinstance(err, dict) and err.get("code") == SIGNAL_RPC_ERROR_RATELIMIT:
return True
message = (
str(err.get("message", ""))
if isinstance(err, dict)
else str(err)
)
msg_lower = message.lower()
return (
"[429]" in message
or "ratelimit" in msg_lower
or "retrylaterexception" in msg_lower
or "retry after" in msg_lower
)
# ---------------------------------------------------------------------------
# Misc helpers
# ---------------------------------------------------------------------------
def _format_wait(seconds: float) -> str:
"""Human-friendly wait label for user-facing pacing notices."""
s = max(0.0, seconds)
if s < 90:
return f"{int(round(s))}s"
return f"{max(1, int(round(s / 60)))} min"
def _signal_send_timeout(num_attachments: int) -> float:
"""HTTP timeout for a Signal ``send`` RPC.
signal-cli uploads attachments serially during the call, so the
server-side time scales with batch size. Default 30s is fine for
text-only sends but truncates large attachment batches mid-upload —
we then log a phantom failure even though signal-cli completes the
send a few seconds later. Scale at 5s/attachment with a 60s floor.
"""
if num_attachments <= 0:
return 30.0
return max(60.0, 5.0 * num_attachments)
# ---------------------------------------------------------------------------
# Scheduler
# ---------------------------------------------------------------------------
class SignalAttachmentScheduler:
"""Process-wide token-bucket simulator for Signal attachment sends.
The bucket holds up to ``capacity`` tokens (default 50, matching
Signal's server-side rate-limit bucket size). Each attachment consumes one
token. Tokens refill at ``refill_rate`` tokens/second, calibrated
from the per-token Retry-After hint we get from the server when a
429 fires. Until we've observed one, we use the documented default
(1 token / 4 seconds).
Concurrent ``acquire(n)`` calls serialize through an
``asyncio.Lock`` — natural FIFO across agent sessions hitting the
same daemon.
"""
def __init__(
self,
capacity: float = float(SIGNAL_RATE_LIMIT_BUCKET_CAPACITY),
default_retry_after: float = float(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER),
) -> None:
self.capacity = float(capacity)
self.tokens = float(capacity)
self.refill_rate = 1.0 / float(default_retry_after)
self.last_refill = time.monotonic()
self._lock = asyncio.Lock()
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _refill(self) -> None:
now = time.monotonic()
elapsed = now - self.last_refill
if elapsed > 0 and self.tokens < self.capacity:
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
self.last_refill = now
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def estimate_wait(self, n: int) -> float:
"""Best-effort estimate of the seconds until ``n`` tokens would
be available. Used to decide whether to emit a user-facing
pacing notice *before* committing to an ``acquire`` that may
block silently. Lock-free; small races vs. concurrent acquires
are benign for an informational notice.
"""
now = time.monotonic()
elapsed = now - self.last_refill
projected = self.tokens
if elapsed > 0 and projected < self.capacity:
projected = min(self.capacity, projected + elapsed * self.refill_rate)
deficit = n - projected
if deficit <= 0:
return 0.0
return deficit / self.refill_rate
async def acquire(self, n: int) -> float:
"""Block until at least ``n`` tokens are available, return the
seconds slept.
Does **not** deduct tokens — the bucket is a read-only model of
server-side capacity. Call ``report_rpc_duration()`` after the
RPC to synchronise the model with the server timeline.
Not perfect in case lots of coroutines try to acquire for big
uploads (``report_rpc_duration`` will take a long time to get hit)
but this is just a simulation. Signal server is ground truth and
will raise rate-limit exceptions triggering requeues.
The lock is released during ``asyncio.sleep`` so other callers
can interleave. A retry loop re-checks after each sleep in
case the deadline was pessimistic.
"""
if n <= 0:
return 0.0
if n > self.capacity:
raise SignalSchedulerError(
f"Signal scheduler was called requesting {n} tokens "
f"(max is {self.capacity})",
)
total_slept = 0.0
first_pass = True
while True:
async with self._lock:
self._refill()
if self.tokens >= n:
if not first_pass or total_slept > 0:
logger.debug(
"Signal scheduler: tokens sufficient for %d "
"(remaining=%.1f, total_slept=%.1fs)",
n, self.tokens, total_slept,
)
return total_slept
deficit = n - self.tokens
wait = deficit / self.refill_rate
if first_pass:
logger.info(
"Signal scheduler: pausing %.1fs for %d tokens "
"(available=%.1f, deficit=%.1f, refill=%.4f/s ≈ %.1fs/token)",
wait, n, self.tokens, deficit,
self.refill_rate, 1.0 / self.refill_rate,
)
first_pass = False
await asyncio.sleep(wait)
total_slept += wait
async def report_rpc_duration(self, rpc_duration: float, n_attachments: int) -> None:
"""Record an attachment-send RPC that just completed.
Deducts ``n_attachments`` tokens without crediting refill during
the upload window. Signal's server checks the bucket at RPC start
and does *not* refill during request processing — refill resumes
after the response. Crediting upload-time refill causes cumulative
drift that eventually triggers 429s.
Advances ``last_refill`` so the next ``acquire`` / ``_refill``
starts counting from this point.
"""
if n_attachments <= 0:
return
async with self._lock:
now = time.monotonic()
token_before = self.tokens
self.tokens = max(0.0, token_before - float(n_attachments))
self.last_refill = now
logger.log(
logging.INFO if rpc_duration > 10 and n_attachments > 5 else logging.DEBUG,
"Signal scheduler: RPC for %d att took %.1fs — "
"tokens %.1f%.1f (deducted=%d, no upload refill credited, refill=%.4fs⁻¹)",
n_attachments, rpc_duration,
token_before, self.tokens,
n_attachments, self.refill_rate,
)
def feedback(self, retry_after: Optional[float], n_attempted: int) -> None:
"""Apply server feedback after a 429.
``retry_after`` is the per-*token* refill window the server
reports (None when signal-cli is older than v0.14.3 and didn't
surface it).
When present we calibrate ``refill_rate`` from it:
the server is authoritative.
"""
if retry_after and retry_after > 0:
new_rate = 1.0 / float(retry_after)
if new_rate != self.refill_rate:
logger.info(
"Signal scheduler: calibrating refill_rate to %.4f tokens/sec "
"(server retry_after=%.1fs per token)",
new_rate, retry_after,
)
self.refill_rate = new_rate
self.tokens = 0.0
self.last_refill = time.monotonic()
def state(self) -> dict:
"""Return current scheduler state for diagnostic logging (read-only).
Does not advance ``last_refill`` — safe to call from logging paths
without perturbing the bucket.
"""
now = time.monotonic()
elapsed = now - self.last_refill
projected = self.tokens
if elapsed > 0 and projected < self.capacity:
projected = min(self.capacity, projected + elapsed * self.refill_rate)
return {
"tokens": round(projected, 1),
"capacity": int(self.capacity),
"refill_rate": round(self.refill_rate, 4),
"refill_seconds_per_token": round(1.0 / self.refill_rate, 1) if self.refill_rate > 0 else float("inf"),
}
# ---------------------------------------------------------------------------
# Process-wide singleton
# ---------------------------------------------------------------------------
_scheduler: Optional[SignalAttachmentScheduler] = None
def get_scheduler() -> SignalAttachmentScheduler:
"""Return the process-wide scheduler, creating it on first access."""
global _scheduler
if _scheduler is None:
_scheduler = SignalAttachmentScheduler()
logger.info(
"Signal scheduler: created (capacity=%d tokens, refill=%.4f/s ≈ %.1fs/token)",
int(_scheduler.capacity),
_scheduler.refill_rate,
1.0 / _scheduler.refill_rate,
)
return _scheduler
def _reset_scheduler() -> None:
"""Drop the cached scheduler so the next ``get_scheduler`` call
builds a fresh one. Test-only — never call from production paths."""
global _scheduler
_scheduler = None
File diff suppressed because it is too large Load Diff
+302
View File
@@ -0,0 +1,302 @@
"""Route-local filters and script transforms for the webhook adapter."""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Optional
logger = logging.getLogger(__name__)
DEFAULT_SCRIPT_TIMEOUT_SECONDS = 30
_MISSING = object()
def _stringify_filter_value(value: Any) -> str:
if value is _MISSING:
return ""
if isinstance(value, (dict, list)):
return json.dumps(value, sort_keys=True)
return str(value)
def _resolve_profile_path(path_value: Any) -> Optional[Path]:
"""Resolve a user path, mapping ~/.hermes to the active profile home."""
if not isinstance(path_value, str):
return None
raw = os.path.expandvars(path_value.strip())
if not raw:
return None
from hermes_constants import get_hermes_home
hermes_home = get_hermes_home()
if raw == "~/.hermes":
return hermes_home
if raw.startswith("~/.hermes/"):
return hermes_home / raw.removeprefix("~/.hermes/")
path = Path(raw).expanduser()
if path.is_absolute():
return path
return hermes_home / path
def _resolve_script_path(script_value: Any) -> tuple[Optional[Path], Optional[str]]:
"""Resolve a route script under HERMES_HOME/scripts."""
if not isinstance(script_value, str) or not script_value.strip():
return None, "script path is empty"
from hermes_constants import get_hermes_home
scripts_root = (get_hermes_home() / "scripts").resolve()
raw_text = os.path.expandvars(script_value.strip())
if raw_text == "~/.hermes" or raw_text.startswith("~/.hermes/"):
mapped = _resolve_profile_path(raw_text)
candidate = mapped.resolve() if mapped is not None else scripts_root
else:
raw = Path(raw_text).expanduser()
candidate = raw.resolve() if raw.is_absolute() else (scripts_root / raw).resolve()
try:
candidate.relative_to(scripts_root)
except ValueError:
return None, f"script path resolves outside {scripts_root}"
if not candidate.exists():
return None, f"script not found: {candidate}"
if not candidate.is_file():
return None, f"script path is not a file: {candidate}"
return candidate, None
def _load_filter_file_values(path_value: Any) -> list[Any]:
path = _resolve_profile_path(path_value)
if path is None:
return []
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning("[webhook] filter in_file read failed for %s: %s", path, exc)
return []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return [line.strip() for line in raw.splitlines() if line.strip()]
if isinstance(data, list):
return data
if isinstance(data, dict):
return list(data.keys())
return [data]
class WebhookRouteProcessor:
"""Evaluate declarative filters and optional script transforms."""
def __init__(
self,
*,
script_timeout_seconds: int = DEFAULT_SCRIPT_TIMEOUT_SECONDS,
) -> None:
self.script_timeout_seconds = max(1, int(script_timeout_seconds))
def resolve_filter_field(
self,
field: Any,
payload: dict,
event_type: str,
headers: Any,
) -> Any:
"""Resolve a dotted filter field against payload/event/headers context."""
if not isinstance(field, str) or not field.strip():
return _MISSING
parts = [part for part in field.strip().split(".") if part]
if not parts:
return _MISSING
header_dict = dict(headers or {})
context = {
"payload": payload.get("payload", payload),
"event": event_type,
"event_type": event_type,
"headers": header_dict,
}
if parts[0] in context:
value: Any = context[parts[0]]
parts = parts[1:]
else:
value = payload
for part in parts:
if isinstance(value, dict):
value = value.get(part, _MISSING)
elif isinstance(value, list) and part.isdigit():
idx = int(part)
value = value[idx] if 0 <= idx < len(value) else _MISSING
else:
return _MISSING
if value is _MISSING:
return _MISSING
return value
def filter_matches(
self,
spec: Any,
payload: dict,
event_type: str,
headers: Any,
) -> bool:
"""Evaluate one declarative webhook filter spec."""
if not isinstance(spec, dict):
logger.warning("[webhook] Ignoring invalid filter spec: %r", spec)
return False
if "all" in spec:
items = spec.get("all")
return isinstance(items, list) and all(
self.filter_matches(item, payload, event_type, headers)
for item in items
)
if "any" in spec:
items = spec.get("any")
return isinstance(items, list) and any(
self.filter_matches(item, payload, event_type, headers)
for item in items
)
if "not" in spec:
return not self.filter_matches(spec.get("not"), payload, event_type, headers)
value = self.resolve_filter_field(
spec.get("field"), payload, event_type, headers
)
if "exists" in spec:
exists = value is not _MISSING
return exists is bool(spec.get("exists"))
if spec.get("missing") is True:
return value is _MISSING
if "equals" in spec:
return value is not _MISSING and value == spec.get("equals")
if "not_equals" in spec:
return value is _MISSING or value != spec.get("not_equals")
if "contains" in spec:
needle = spec.get("contains")
if value is _MISSING:
return False
if isinstance(value, (list, tuple, set, dict)):
return needle in value
return str(needle) in _stringify_filter_value(value)
if "in" in spec:
haystack = spec.get("in")
return isinstance(haystack, list) and value in haystack
if "in_file" in spec:
return value in _load_filter_file_values(spec.get("in_file"))
if "regex" in spec:
if value is _MISSING:
return False
try:
return (
re.search(str(spec.get("regex")), _stringify_filter_value(value))
is not None
)
except re.error as exc:
logger.warning("[webhook] Invalid webhook filter regex: %s", exc)
return False
logger.warning("[webhook] Filter spec has no supported operator: %r", spec)
return False
def route_filters_match(
self,
route_config: dict,
payload: dict,
event_type: str,
headers: Any,
) -> bool:
filters = route_config.get("filters") or []
if not filters:
return True
if isinstance(filters, dict):
return self.filter_matches(filters, payload, event_type, headers)
if not isinstance(filters, list):
logger.warning("[webhook] filters must be a list or object")
return False
return all(
self.filter_matches(spec, payload, event_type, headers)
for spec in filters
)
def run_route_script(self, script_value: Any, payload: dict) -> tuple[bool, Optional[dict]]:
"""Run a route script and return (should_continue, transformed_payload)."""
path, error = _resolve_script_path(script_value)
if error or path is None:
logger.warning("[webhook] script ignored webhook: %s", error)
return False, None
suffix = path.suffix.lower()
if suffix in {".sh", ".bash"}:
bash = shutil.which("bash") or (
"/bin/bash" if os.path.isfile("/bin/bash") else None
)
if bash is None:
logger.warning("[webhook] script ignored webhook: bash not found")
return False, None
argv = [bash, str(path)]
else:
argv = [sys.executable, str(path)]
try:
from tools.environments.local import build_subprocess_env
popen_kwargs = {"creationflags": 0x08000000} if sys.platform == "win32" else {}
result = subprocess.run(
argv,
input=json.dumps(payload),
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=self.script_timeout_seconds,
cwd=str(path.parent),
env=build_subprocess_env(),
**popen_kwargs,
)
except subprocess.TimeoutExpired:
logger.warning("[webhook] script timed out: %s", path)
return False, None
except Exception as exc:
logger.warning("[webhook] script execution failed: %s", exc)
return False, None
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()
try:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
stderr = redact_sensitive_text(stderr)
except Exception as exc:
logger.warning("[webhook] Failed to redact script output: %s", exc)
stdout = "[REDACTED - redaction failed]"
stderr = "[REDACTED - redaction failed]"
if result.returncode != 0:
logger.info(
"[webhook] script ignored webhook path=%s code=%s stderr=%s",
path.name,
result.returncode,
stderr[:200],
)
return False, None
if not stdout or stdout == "[SILENT]":
return False, None
try:
transformed = json.loads(stdout)
except json.JSONDecodeError:
transformed = {**payload, "script_output": stdout}
if not isinstance(transformed, dict):
logger.warning("[webhook] script stdout must be a JSON object or text")
return False, None
if (
transformed.get("[SILENT]") is True
or transformed.get("__hermes_ignore__") is True
):
return False, None
return True, transformed
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+552
View File
@@ -0,0 +1,552 @@
"""
Transport-agnostic WhatsApp behavior shared by the Baileys bridge adapter
and the official WhatsApp Cloud API adapter.
The mixin provides:
- Allow-list / DM / group gating
- Mention detection (explicit @-mentions + configurable regex patterns)
- Quoted-reply-to-bot detection
- Broadcast / Channel / Newsletter filtering
- WhatsApp-flavored markdown conversion
- Outgoing chunk length budgeting
It is the *behavior layer*. Transport-specific concerns (subprocess management,
HTTP webhooks, Graph API calls, media upload protocols) live in each adapter.
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.
"""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Any, Dict, Optional
from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError
from agent.secret_scope import get_secret as _scoped_get_secret
def _get_wsecret(name, default=None):
"""Scope-aware WHATSAPP_* read with the default-profile startup fallback.
Secondary profiles run under ``_profile_runtime_scope`` -- the scope is
authoritative and a scoped miss returns ``default`` (no cross-profile
borrow). The DEFAULT profile's adapter constructs and sends *unscoped*
under multiplexing, where a bare ``get_secret`` would raise
``UnscopedSecretError`` and crash its WhatsApp path; there ``os.environ``
is that profile's own value, so fall back to it. Same pattern as the
Slack ``SLACK_APP_TOKEN`` read (#59739).
"""
try:
val = _scoped_get_secret(name, default)
except _UnscopedSecretError:
val = os.getenv(name)
return val if val is not None else default
logger = logging.getLogger(__name__)
class WhatsAppBehaviorMixin:
"""Shared behavior for all WhatsApp adapters (Baileys + Cloud API).
See module docstring for the attribute contract the host adapter must
satisfy. This mixin owns no state of its own every value it touches
is either a class attribute or set by the adapter's ``__init__``.
"""
# WhatsApp message limits — practical UX limit, not protocol max.
# WhatsApp allows ~65K but long messages are unreadable on mobile.
MAX_MESSAGE_LENGTH: int = 4096
supports_code_blocks = True # WhatsApp renders fenced code blocks (monospace)
DEFAULT_REPLY_PREFIX: str = "⚕ *Hermes Agent*\n────────────\n"
_OUTBOUND_INVISIBLE_CHARS_RE = re.compile(r"[\u200b\u2060\u2063\ufeff]")
_OUTBOUND_ODD_SPACE_RE = re.compile(r"[\u00a0\u1680\u180e\u2000-\u200a\u202f\u205f\u3000]")
@classmethod
def _sanitize_outbound_text(cls, content: str) -> str:
"""Remove invisible formatting chars that leak badly in WhatsApp.
Some provider/gateway formatting paths can emit unicode like WORD
JOINER (U+2060) plus NARROW NO-BREAK SPACE (U+202F). WhatsApp may
render those as mojibake-looking prefixes (``text``) instead of
invisible spacing. Keep normal text and emoji joiners intact, but
strip known zero-width format chars and normalize odd unicode spaces.
"""
if not content:
return content
content = cls._OUTBOUND_INVISIBLE_CHARS_RE.sub("", content)
return cls._OUTBOUND_ODD_SPACE_RE.sub(" ", content)
@property
def enforces_own_access_policy(self) -> bool:
"""WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
return True
# ------------------------------------------------------------------ config
def _effective_reply_prefix(self) -> str:
"""Return the prefix to add to outgoing replies in self-chat mode.
Subclasses that don't have a self-chat concept (the Cloud API
adapter) can override this to always return ``""`` or apply a
different policy.
"""
whatsapp_mode = _get_wsecret("WHATSAPP_MODE", default="self-chat") or "self-chat"
if whatsapp_mode != "self-chat":
return ""
if self._reply_prefix is not None:
return self._reply_prefix.replace("\\n", "\n")
env_prefix = _get_wsecret("WHATSAPP_REPLY_PREFIX")
if env_prefix is not None:
return env_prefix.replace("\\n", "\n")
return self.DEFAULT_REPLY_PREFIX
def _outgoing_chunk_limit(self) -> int:
"""Reserve room for the reply prefix so the final message fits."""
prefix_len = len(self._effective_reply_prefix())
# Keep enough space for truncate_message's pagination indicator and
# code-fence repair even if a user configures a very long prefix.
return max(1024, self.MAX_MESSAGE_LENGTH - prefix_len)
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 {"true", "1", "yes", "on"}
return bool(configured)
return (_get_wsecret("WHATSAPP_REQUIRE_MENTION", default="false") or "false").lower() in {
"true",
"1",
"yes",
"on",
}
def _whatsapp_free_response_chats(self) -> set[str]:
raw = self.config.extra.get("free_response_chats")
if raw is None:
raw = _get_wsecret("WHATSAPP_FREE_RESPONSE_CHATS", default="") or ""
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()}
@staticmethod
def _coerce_allow_list(raw) -> set[str]:
"""Parse allow_from / group_allow_from from config or env var."""
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()}
def _live_dm_allow_from(self) -> set[str]:
"""Allowlist currently enforced for DM intake / strict DM auth.
Source precedence matches construction: explicit config wins over any
env carrier. When the adapter was seeded from an env var, re-read that
same key so pairing approve/revoke takes effect without restart
(including an empty value while the key is still present). When the key
is absent sole-entry revoke calls ``remove_env_value`` treat the
allowlist as empty instead of falling back to the construction-time
snapshot. Config-seeded adapters keep the in-memory snapshot, which
pairing revoke purges in place a lower-precedence or stale env value
must not broaden access.
"""
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, ""))
# Key removed (e.g. sole-entry pairing revoke) — do not revive the
# stale construction snapshot.
return set()
return set(self._allow_from or ())
# ------------------------------------------------------------------ JID helpers
@staticmethod
def _normalize_whatsapp_id(value: Optional[str]) -> str:
if not value:
return ""
normalized = str(value).strip()
if ":" in normalized and "@" in normalized:
normalized = normalized.replace(":", "@", 1)
return normalized
@staticmethod
def _is_broadcast_chat(chat_id: str) -> bool:
"""True for WhatsApp pseudo-chats that aren't real conversations.
Covers Status updates (Stories) and Channel/Newsletter broadcasts.
These show up as inbound messages on Baileys but the agent should
never reply answering a Story update spams the contact's status
feed, and Channel posts aren't addressable in the first place.
"""
if not chat_id:
return False
cid = chat_id.strip().lower()
if cid == "status@broadcast":
return True
# @broadcast suffix covers status@broadcast plus any future
# broadcast-list variants. @newsletter is the Channel JID suffix.
if cid.endswith("@broadcast") or cid.endswith("@newsletter"):
return True
return False
# ------------------------------------------------------------------ gating
def _open_dm_opted_in(self) -> bool:
if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}:
return True
return (_get_wsecret("WHATSAPP_ALLOW_ALL_USERS", default="") or "").lower() in {"true", "1", "yes"}
@staticmethod
def _matches_whatsapp_allowlist(candidate: str, allow_from) -> bool:
"""Match a WhatsApp identifier against an allowlist across phone/LID forms.
WhatsApp delivers inbound senders in LID form (``<id>@lid``) while
operators usually configure allowlists with phone numbers, and vice
versa. A raw set-membership check therefore never matches a known
contact. Resolve both the candidate and each allowlist entry through
the bridge's ``lid-mapping-*.json`` files (the shared
``gateway.whatsapp_identity`` helper that the gateway authz and
session-key paths already use) so either configured form resolves to
the inbound form.
"""
if not allow_from:
return False
# Fast path: exact match against the raw configured value (e.g. a full
# ``@g.us`` group JID or an entry that already matches verbatim).
if candidate in allow_from:
return True
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
# Entry may itself be an unmapped form; expand it too so a phone
# allowlist entry resolves when the inbound sender arrived as a LID.
if expand_whatsapp_aliases(entry) & candidate_aliases:
return True
return False
def _is_dm_allowed(self, sender_id: str) -> bool:
"""Strict DM authorization — pairing does not imply access."""
if self._dm_policy == "disabled":
return False
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
def _is_dm_intake_allowed(self, sender_id: str) -> bool:
"""Whether a DM may reach the gateway intake (pairing handshake path)."""
principal = str(sender_id or "").strip()
if not principal:
return False
if self._dm_policy == "disabled":
return False
if self._dm_policy == "allowlist":
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
def _is_group_allowed(self, chat_id: str) -> bool:
"""Check whether a group chat should be processed."""
if self._group_policy == "disabled":
return False
if self._group_policy == "allowlist":
return self._matches_whatsapp_allowlist(chat_id, self._group_allow_from)
if self._group_policy == "pairing":
return False
if self._group_policy == "open":
return True
return False
def _compile_mention_patterns(self):
patterns = self.config.extra.get("mention_patterns")
if patterns is None:
raw = (_get_wsecret("WHATSAPP_MENTION_PATTERNS", default="") or "").strip()
if raw:
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()
]
if patterns is None:
return []
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__,
)
return []
compiled = []
for pattern in patterns:
if not isinstance(pattern, str) or not pattern.strip():
continue
try:
compiled.append(re.compile(pattern, re.IGNORECASE))
except re.error as exc:
logger.warning(
"[%s] Invalid WhatsApp mention pattern %r: %s",
self.name,
pattern,
exc,
)
if compiled:
logger.info(
"[%s] Loaded %d WhatsApp mention pattern(s)", self.name, len(compiled)
)
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
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)
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:
return True
body = str(data.get("body") or "")
lower_body = body.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
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)
def _clean_bot_mention_text(self, text: str, data: Dict[str, Any]) -> str:
if not text:
return text
bot_ids = self._bot_ids_from_message(data)
cleaned = text
for bot_id in bot_ids:
bare_id = bot_id.split("@", 1)[0]
if bare_id:
cleaned = re.sub(
rf"@{re.escape(bare_id)}\b[,:\-]*\s*", "", cleaned
)
return cleaned.strip() or text
def _should_process_message(self, data: Dict[str, Any]) -> bool:
chat_id_raw = str(data.get("chatId") or "")
# WhatsApp uses pseudo-chats for Status updates (Stories) and
# Channel/Newsletter broadcasts. These are not real conversations
# and the agent should never reply to them — even in self-chat mode
# where the bridge may surface them as "fromMe" events.
if self._is_broadcast_chat(chat_id_raw):
return False
is_group = data.get("isGroup", False)
if is_group:
chat_id = chat_id_raw
if not self._is_group_allowed(chat_id):
return False
else:
sender_id = str(data.get("senderId") or data.get("from") or "")
if not self._is_dm_intake_allowed(sender_id):
return False
# DMs that pass the policy gate are always processed
return True
# Group messages: check mention / free-response settings
chat_id = str(data.get("chatId") or "")
if chat_id in self._whatsapp_free_response_chats():
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)
# ------------------------------------------------------------------ formatting
def format_message(self, content: str) -> str:
"""Convert standard markdown to WhatsApp-compatible formatting.
WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```,
and monospaced `inline`. Standard markdown uses different syntax
for bold/italic/strikethrough, so we convert here.
Code blocks (``` fenced) and inline code (`) are protected from
conversion via placeholder substitution.
"""
if not content:
return content
content = self._sanitize_outbound_text(content)
# --- 1. Protect fenced code blocks from formatting changes ---
_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)
# --- 2. Protect inline code ---
_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)
# --- 3. Convert markdown formatting to WhatsApp syntax ---
# Italic: standard Markdown *text* → WhatsApp _text_. Do this before
# bold conversion so **bold** does not become italic by accident. The
# lookarounds avoid list bullets and bold delimiters.
result = re.sub(
r"(?<!\*)\*(?!\s|\*)([^*\n]*?\S[^*\n]*?)\*(?!\*)",
r"_\1_",
result,
)
# Bold: **text** or __text__ → *text*
result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result)
result = re.sub(r"__(.+?)__", r"*\1*", result)
# Strikethrough: ~~text~~ → ~text~
result = re.sub(r"~~(.+?)~~", r"~\1~", result)
# _text_ is already WhatsApp italic — leave as-is
# --- 4. Convert markdown headers to bold text ---
# # Header → *Header*. Strip any *...* wrapping already produced
# by step 3 (e.g. "# **Title**" → "*Title*", not "**Title**",
# which WhatsApp renders with literal asterisks).
def _header_to_bold(m: re.Match) -> 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
)
# --- 5. Convert markdown links: [text](url) → text (url) ---
result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result)
# --- 6. Restore protected sections ---
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)
return result
# ---------------------------------------------------------------------------
# Shared bridge directory resolution for CLI and adapter
# ---------------------------------------------------------------------------
def resolve_whatsapp_bridge_dir() -> Path:
"""Resolve the WhatsApp bridge directory, mirroring to HERMES_HOME if needed.
When the install tree is read-only (e.g., Docker /opt/hermes), this function
mirrors the bridge source to a writable HERMES_HOME location and returns that
path. This ensures npm install works in Docker environments.
Returns the resolved bridge directory path.
"""
import shutil
from pathlib import Path as _Path
# Default location in install tree (may be read-only)
from hermes_constants import get_hermes_home
install_bridge = _Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
# Try HERMES_HOME location first
hermes_home = get_hermes_home()
hermes_home_bridge = hermes_home / "scripts" / "whatsapp-bridge"
# Check if install dir is writable
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:
return install_bridge
# Install dir is read-only, mirror to HERMES_HOME if needed
if hermes_home_bridge.exists():
return hermes_home_bridge
# Mirror the bridge source to HERMES_HOME
try:
hermes_home_bridge.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(
install_bridge,
hermes_home_bridge,
dirs_exist_ok=False,
)
return hermes_home_bridge
except Exception:
return install_bridge
File diff suppressed because it is too large Load Diff
+665
View File
@@ -0,0 +1,665 @@
"""
yuanbao_media.py 元宝平台媒体处理模块
提供 COS 上传文件下载TIM 媒体消息构建等功能
移植自 TypeScript media.tsyuanbao-openclaw-plugin
使用 httpx 替代 cos-nodejs-sdk-v5避免引入额外 SDK 依赖
COS 上传流程
1. 调用 genUploadInfo 获取临时凭证tmpSecretId/tmpSecretKey/sessionToken
2. 用临时凭证通过 HMAC-SHA1 签名构建 Authorization
3. HTTP PUT 上传到 COS
TIM 消息体构建
- buildImageMsgBody() TIMImageElem
- buildFileMsgBody() TIMFileElem
"""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import struct
import time
import urllib.parse
from typing import Optional, Any
import httpx
logger = logging.getLogger(__name__)
# ============ 常量 ============
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,
"image/webp": 255,
"image/heic": 255,
"image/tiff": 255,
}
# 文件扩展名 → MIME
_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",
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".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",
}
# ============ 工具函数 ============
def guess_mime_type(filename: str) -> str:
"""根据文件扩展名猜测 MIME 类型。"""
ext = os.path.splitext(filename)[-1].lower()
return _EXT_TO_MIME.get(ext, "application/octet-stream")
def is_image(filename: str, mime_type: str = "") -> bool:
"""判断是否为图片类型。"""
if mime_type.startswith("image/"):
return True
ext = os.path.splitext(filename)[-1].lower()
return ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".heic", ".tiff", ".ico"}
def get_image_format(mime_type: str) -> int:
"""获取 TIM 图片格式编号。"""
return _MIME_TO_IMAGE_FORMAT.get(mime_type.lower(), 255)
def md5_hex(data: bytes) -> str:
"""计算 MD5 十六进制摘要。"""
return hashlib.md5(data).hexdigest()
def generate_file_id() -> str:
"""生成随机文件 ID(32 位 hex)。"""
return secrets.token_hex(16)
# ============ 图片尺寸解析(纯 Python,无需 Pillow ============
def parse_image_size(data: bytes) -> Optional[dict[str, int]]:
"""
解析图片宽高支持 JPEG/PNG/GIF/WebP无需第三方依赖
返回 {"width": w, "height": h} None无法识别
"""
return (
_parse_png_size(data)
or _parse_jpeg_size(data)
or _parse_gif_size(data)
or _parse_webp_size(data)
)
def _parse_png_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 24:
return None
if buf[:4] != b"\x89PNG":
return None
w = struct.unpack(">I", buf[16:20])[0]
h = struct.unpack(">I", buf[20:24])[0]
return {"width": w, "height": h}
def _parse_jpeg_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 4 or buf[0] != 0xFF or buf[1] != 0xD8:
return None
i = 2
while i < len(buf) - 9:
if buf[i] != 0xFF:
i += 1
continue
marker = buf[i + 1]
if marker in {0xC0, 0xC2}:
h = struct.unpack(">H", buf[i + 5: i + 7])[0]
w = struct.unpack(">H", buf[i + 7: i + 9])[0]
return {"width": w, "height": h}
if i + 3 < len(buf):
i += 2 + struct.unpack(">H", buf[i + 2: i + 4])[0]
else:
break
return None
def _parse_gif_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 10:
return None
sig = buf[:6].decode("ascii", errors="replace")
if sig not in {"GIF87a", "GIF89a"}:
return None
w = struct.unpack("<H", buf[6:8])[0]
h = struct.unpack("<H", buf[8:10])[0]
return {"width": w, "height": h}
def _parse_webp_size(buf: bytes) -> Optional[dict[str, int]]:
if len(buf) < 16:
return None
if 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] == 0x9D and buf[24] == 0x01 and buf[25] == 0x2A:
w = struct.unpack("<H", buf[26:28])[0] & 0x3FFF
h = struct.unpack("<H", buf[28:30])[0] & 0x3FFF
return {"width": w, "height": h}
elif chunk == "VP8L":
if len(buf) >= 25 and buf[20] == 0x2F:
bits = struct.unpack("<I", buf[21:25])[0]
w = (bits & 0x3FFF) + 1
h = ((bits >> 14) & 0x3FFF) + 1
return {"width": w, "height": h}
elif chunk == "VP8X":
if len(buf) >= 30:
w = (buf[24] | (buf[25] << 8) | (buf[26] << 16)) + 1
h = (buf[27] | (buf[28] << 8) | (buf[29] << 16)) + 1
return {"width": w, "height": h}
return None
# ============ URL 下载 ============
async def download_url(
url: str,
max_size_mb: int = DEFAULT_MAX_SIZE_MB,
) -> tuple[bytes, str]:
"""
下载 URL 内容返回 (bytes, content_type)
Args:
url: HTTP(S) URL
max_size_mb: 最大允许大小MB超过则抛出异常
Returns:
(data_bytes, content_type_string)
Raises:
ValueError: 内容超过大小限制
httpx.HTTPError: 网络/HTTP 错误
"""
# SSRF protection: yuanbao downloads model-supplied and inbound URLs
# server-side. Reject private/internal targets up front, and re-validate
# every redirect hop so a public URL can't 302 to http://169.254.169.254/.
from tools.url_safety import create_ssrf_safe_async_client, is_safe_url
if not is_safe_url(url):
raise ValueError(f"Blocked unsafe URL (SSRF protection): {url}")
async def _redirect_guard(response: httpx.Response) -> None:
if response.is_redirect and response.next_request:
redirect_url = str(response.next_request.url)
if not is_safe_url(redirect_url):
raise ValueError(
f"Blocked redirect to private/internal address: {redirect_url}"
)
max_bytes = max_size_mb * 1024 * 1024
async with create_ssrf_safe_async_client(
timeout=30.0,
follow_redirects=True,
event_hooks={"response": [_redirect_guard]},
) as client:
# 先 HEAD 检查大小
try:
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 # 部分服务器不支持 HEAD,忽略
# GET 下载(流式读取,防止超限)
async with client.stream("GET", url) as resp:
resp.raise_for_status()
content_type = resp.headers.get("content-type", "").split(";")[0].strip()
chunks: list[bytes] = []
downloaded = 0
async for chunk in resp.aiter_bytes(65536):
downloaded += len(chunk)
if downloaded > max_bytes:
raise ValueError(
f"文件过大: 已超过 {max_size_mb} MB 限制"
)
chunks.append(chunk)
data = b"".join(chunks)
return data, content_type
# ============ COS 鉴权(HMAC-SHA1 ============
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,
) -> str:
"""
构建 COS 请求签名q-sign-algorithm=sha1 方案
参考https://cloud.tencent.com/document/product/436/7778
Args:
method: HTTP 方法小写 "put"
path: URL 路径URL encode 后的小写
params: URL 查询参数 dict用于签名
headers: 参与签名的请求头 dictkey 需小写
secret_id: 临时 SecretIdtmpSecretId
secret_key: 临时 SecretKeytmpSecretKey
start_time: 签名起始 Unix 时间戳默认 now
expire_seconds: 签名有效期默认 3600
Returns:
Authorization header 完整字符串
"""
now = int(time.time())
q_sign_time = f"{start_time or now};{(start_time or now) + expire_seconds}"
# Step 1: SignKey = HMAC-SHA1(SecretKey, q-sign-time)
sign_key = hmac.new(
secret_key.encode("utf-8"),
q_sign_time.encode("utf-8"),
hashlib.sha1,
).hexdigest()
# Step 2: HttpString
# 参数和头部需按字典序排列,key 小写
sorted_params = sorted((k.lower(), urllib.parse.quote(str(v), safe="") ) for k, v in params.items())
sorted_headers = sorted((k.lower(), urllib.parse.quote(str(v), safe="") ) for k, v in headers.items())
url_param_list = ";".join(k for k, _ in sorted_params)
url_params = "&".join(f"{k}={v}" for k, v in sorted_params)
header_list = ";".join(k for k, _ in sorted_headers)
header_str = "&".join(f"{k}={v}" for k, v in sorted_headers)
http_string = "\n".join([
method.lower(),
path,
url_params,
header_str,
"",
])
# Step 3: StringToSign = sha1 hash of HttpString
sha1_of_http = hashlib.sha1(http_string.encode("utf-8")).hexdigest()
string_to_sign = "\n".join([
"sha1",
q_sign_time,
sha1_of_http,
"",
])
# Step 4: Signature = HMAC-SHA1(SignKey, StringToSign)
signature = hmac.new(
sign_key.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).hexdigest()
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-header-list={header_list}"
f"&q-url-param-list={url_param_list}"
f"&q-signature={signature}"
)
# ============ 主要公开 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 = "",
) -> dict:
"""
调用 genUploadInfo 接口获取 COS 临时密钥及上传配置
Args:
app_key: 应用 Key用于 X-ID
api_domain: API 域名 https://bot.yuanbao.tencent.com
token: 当前有效的签票 tokenX-Token
filename: 待上传的文件名含扩展名
file_id: 客户端生成的唯一文件 ID不传则自动生成
bot_id: Bot 账号 ID用于 X-ID
Returns:
COS 上传配置 dict包含以下字段
bucketName (str) COS Bucket 名称
region (str) COS 地域
location (str) 上传 Key对象路径
encryptTmpSecretId (str) 临时 SecretId
encryptTmpSecretKey(str) 临时 SecretKey
encryptToken (str) SessionToken
startTime (int) 凭证起始时间戳Unix
expiredTime (int) 凭证过期时间戳Unix
resourceUrl (str) 上传后的公网访问 URL
resourceID (str) 资源 ID可选
Raises:
RuntimeError: 接口返回非 0 code 或字段缺失
"""
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": "",
}
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(upload_url, 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', '')}"
)
data = result.get("data") or result
required_fields = ["bucketName", "location"]
missing = [f for f in required_fields if not data.get(f)]
if missing:
raise RuntimeError(
f"genUploadInfo 返回字段不完整: 缺少字段 {missing}"
)
return data
async def upload_to_cos(
file_bytes: bytes,
filename: str,
content_type: str,
credentials: dict,
bucket: str,
region: str,
) -> dict:
"""
通过 httpx PUT 请求将文件上传到 COS
使用临时凭证tmpSecretId/tmpSecretKey/sessionToken构建 HMAC-SHA1 签名
Args:
file_bytes: 文件二进制内容
filename: 文件名用于辅助计算 MIMEUUID
content_type: MIME 类型 "image/jpeg"
credentials: get_cos_credentials() 返回的 dict包含
encryptTmpSecretId tmpSecretId
encryptTmpSecretKey tmpSecretKey
encryptToken sessionToken
location COS key对象路径
resourceUrl 上传后公网 URL
startTime 凭证起始时间Unix
expiredTime 凭证过期时间Unix
bucket: COS Bucket 名称 chatbot-1234567890
region: COS 地域 ap-guangzhou
Returns:
上传结果 dict包含
url (str) COS 公网访问 URL
uuid (str) 文件内容 MD5
size (int) 文件大小字节
width (int, optional) 图片宽度仅图片
height (int, optional) 图片高度仅图片
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)}"
)
# 构建 COS 上传 URL(优先使用全球加速域名)
if COS_USE_ACCELERATE:
cos_host = f"{bucket}.cos.accelerate.myqcloud.com"
else:
cos_host = f"{bucket}.cos.{region}.myqcloud.com"
# URL encode cos_key(保留 /
encoded_key = urllib.parse.quote(cos_key, safe="/")
cos_url = f"https://{cos_host}/{encoded_key.lstrip('/')}"
# 确定 Content-Type
if not content_type or content_type == "application/octet-stream":
if is_image(filename):
content_type = guess_mime_type(filename)
else:
content_type = "application/octet-stream"
# 计算文件 MD5 + size
file_uuid = md5_hex(file_bytes)
file_size = len(file_bytes)
# 参与签名的请求头
sign_headers = {
"host": cos_host,
"content-type": content_type,
"x-cos-security-token": session_token,
}
# 计算签名有效期
now = int(time.time())
sign_start = start_time if start_time else now
sign_expire = (expired_time - now) if expired_time and expired_time > now else 3600
authorization = _cos_sign(
method="put",
path=f"/{encoded_key.lstrip('/')}",
params={},
headers=sign_headers,
secret_id=secret_id,
secret_key=secret_key,
start_time=sign_start,
expire_seconds=sign_expire,
)
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,
}
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"]
logger.info(
"COS 上传成功: url=%s size=%d",
result["url"], file_size,
)
return result
# ============ TIM 媒体消息构建 ============
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 = "",
) -> list[dict]:
"""
构建腾讯 IM TIMImageElem 消息体
参考https://cloud.tencent.com/document/product/269/2720
Args:
url: 图片公网访问 URLCOS resourceUrl
uuid: 文件 UUIDMD5 或其他唯一标识
filename: 文件名uuid 为空时作为备用
size: 文件大小字节
width: 图片宽度像素
height: 图片高度像素
mime_type: MIME 类型用于确定 image_format
Returns:
TIMImageElem 消息体列表适合直接放入 msg_body
"""
_uuid = uuid or filename or _basename_from_url(url) or "image"
image_format = get_image_format(mime_type) if mime_type else 255
return [
{
"msg_type": "TIMImageElem",
"msg_content": {
"uuid": _uuid,
"image_format": image_format,
"image_info_array": [
{
"type": 1, # 1 = 原图
"size": size,
"width": width,
"height": height,
"url": url,
}
],
},
}
]
def build_file_msg_body(
url: str,
filename: str,
uuid: Optional[str] = None,
size: int = 0,
) -> list[dict]:
"""
构建腾讯 IM TIMFileElem 消息体
参考https://cloud.tencent.com/document/product/269/2720
Args:
url: 文件公网访问 URLCOS resourceUrl
filename: 文件名含扩展名
uuid: 文件 UUIDMD5 或其他唯一标识不传则使用 filename
size: 文件大小字节
Returns:
TIMFileElem 消息体列表适合直接放入 msg_body
"""
_uuid = uuid or filename
return [
{
"msg_type": "TIMFileElem",
"msg_content": {
"uuid": _uuid,
"file_name": filename,
"file_size": size,
"url": url,
},
}
]
# ============ 内部工具 ============
def _basename_from_url(url: str) -> str:
"""从 URL 提取文件名。"""
try:
parsed = urllib.parse.urlparse(url)
return os.path.basename(parsed.path)
except Exception:
return ""
File diff suppressed because it is too large Load Diff
+558
View File
@@ -0,0 +1,558 @@
"""
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": "<json>", # serialised sticker metadata
}
}
The `data` field carries a JSON string with the sticker's metadata so the
receiver can look up the correct asset in the emoji pack.
"""
from __future__ import annotations
import json
import random
import re
import unicodedata
from typing import Optional
# ---------------------------------------------------------------------------
# Sticker catalogue ported from builtin-stickers.json
# Key : canonical name (Chinese)
# Value : {sticker_id, package_id, name, description, width, height, formats}
# ---------------------------------------------------------------------------
STICKER_MAP: dict[str, dict] = {
"六六六": {
"sticker_id": "278", "package_id": "1003", "name": "六六六",
"description": "666 厉害 牛 棒 绝了 好强 awesome",
"width": 128, "height": 128, "formats": "png",
},
"我想开了": {
"sticker_id": "262", "package_id": "1003", "name": "我想开了",
"description": "想开 佛系 释怀 顿悟 看淡了 无所谓",
"width": 128, "height": 128, "formats": "png",
},
"害羞": {
"sticker_id": "130", "package_id": "1003", "name": "害羞",
"description": "腼腆 不好意思 脸红 娇羞 羞涩 捂脸",
"width": 128, "height": 128, "formats": "png",
},
"比心": {
"sticker_id": "252", "package_id": "1003", "name": "比心",
"description": "笔芯 爱你 爱心手势 love heart 喜欢你",
"width": 128, "height": 128, "formats": "png",
},
"委屈": {
"sticker_id": "125", "package_id": "1003", "name": "委屈",
"description": "难过 想哭 可怜巴巴 瘪嘴 受伤 被欺负",
"width": 128, "height": 128, "formats": "png",
},
"亲亲": {
"sticker_id": "146", "package_id": "1003", "name": "亲亲",
"description": "么么 mua 亲一下 kiss 飞吻 啵",
"width": 128, "height": 128, "formats": "png",
},
"": {
"sticker_id": "131", "package_id": "1003", "name": "",
"description": "帅 墨镜 cool 高冷 有型 swagger",
"width": 128, "height": 128, "formats": "png",
},
"": {
"sticker_id": "145", "package_id": "1003", "name": "",
"description": "睡觉 困 zzZ 打盹 躺平 休眠 sleepy",
"width": 128, "height": 128, "formats": "png",
},
"发呆": {
"sticker_id": "152", "package_id": "1003", "name": "发呆",
"description": "懵 愣住 放空 呆滞 出神 脑子空白",
"width": 128, "height": 128, "formats": "png",
},
"可怜": {
"sticker_id": "157", "package_id": "1003", "name": "可怜",
"description": "卖萌 求饶 委屈巴巴 弱小 拜托 眼巴巴",
"width": 128, "height": 128, "formats": "png",
},
"摊手": {
"sticker_id": "200", "package_id": "1003", "name": "摊手",
"description": "无奈 没办法 耸肩 随便 那咋整 whatever",
"width": 128, "height": 128, "formats": "png",
},
"头大": {
"sticker_id": "213", "package_id": "1003", "name": "头大",
"description": "头疼 烦恼 郁闷 难搞 崩溃 一团乱",
"width": 128, "height": 128, "formats": "png",
},
"": {
"sticker_id": "256", "package_id": "1003", "name": "",
"description": "害怕 惊恐 震惊 吓一跳 恐怖 怂",
"width": 128, "height": 128, "formats": "png",
},
"吐血": {
"sticker_id": "203", "package_id": "1003", "name": "吐血",
"description": "无语 崩溃 被雷 内伤 一口老血 屮",
"width": 128, "height": 128, "formats": "png",
},
"": {
"sticker_id": "185", "package_id": "1003", "name": "",
"description": "傲娇 生气 不满 撇嘴 不理 赌气",
"width": 128, "height": 128, "formats": "png",
},
"嘿嘿": {
"sticker_id": "220", "package_id": "1003", "name": "嘿嘿",
"description": "坏笑 猥琐笑 偷笑 憨笑 得意 你懂的",
"width": 128, "height": 128, "formats": "png",
},
"头秃": {
"sticker_id": "218", "package_id": "1003", "name": "头秃",
"description": "程序员 加班 焦虑 没头发 秃了 肝爆",
"width": 128, "height": 128, "formats": "png",
},
"暗中观察": {
"sticker_id": "221", "package_id": "1003", "name": "暗中观察",
"description": "窥屏 潜水 偷偷看 角落 围观 屏住呼吸",
"width": 128, "height": 128, "formats": "png",
},
"我酸了": {
"sticker_id": "224", "package_id": "1003", "name": "我酸了",
"description": "嫉妒 柠檬精 羡慕 吃柠檬 眼红 恰柠檬",
"width": 128, "height": 128, "formats": "png",
},
"打call": {
"sticker_id": "246", "package_id": "1003", "name": "打call",
"description": "应援 加油 支持 喝彩 助威 call",
"width": 128, "height": 128, "formats": "png",
},
"庆祝": {
"sticker_id": "251", "package_id": "1003", "name": "庆祝",
"description": "祝贺 开心 耶 party 胜利 干杯",
"width": 128, "height": 128, "formats": "png",
},
"奋斗": {
"sticker_id": "151", "package_id": "1003", "name": "奋斗",
"description": "努力 加油 拼搏 冲 干劲 卷起来",
"width": 128, "height": 128, "formats": "png",
},
"惊讶": {
"sticker_id": "143", "package_id": "1003", "name": "惊讶",
"description": "震惊 哇 不敢相信 OMG 居然 这么离谱",
"width": 128, "height": 128, "formats": "png",
},
"疑问": {
"sticker_id": "144", "package_id": "1003", "name": "疑问",
"description": "问号 不懂 啥 为什么 啥情况 懵逼问",
"width": 128, "height": 128, "formats": "png",
},
"仔细分析": {
"sticker_id": "248", "package_id": "1003", "name": "仔细分析",
"description": "思考 推敲 认真 研究 琢磨 让我想想",
"width": 128, "height": 128, "formats": "png",
},
"撅嘴": {
"sticker_id": "184", "package_id": "1003", "name": "撅嘴",
"description": "嘟嘴 卖萌 不高兴 撒娇 嘴翘",
"width": 128, "height": 128, "formats": "png",
},
"泪奔": {
"sticker_id": "199", "package_id": "1003", "name": "泪奔",
"description": "大哭 伤心 破防 感动哭 泪流满面 呜呜",
"width": 128, "height": 128, "formats": "png",
},
"尊嘟假嘟": {
"sticker_id": "276", "package_id": "1003", "name": "尊嘟假嘟",
"description": "真的假的 真假 可爱问 你骗我 是不是",
"width": 128, "height": 128, "formats": "png",
},
"略略略": {
"sticker_id": "113", "package_id": "1003", "name": "略略略",
"description": "调皮 吐舌 不服 略 气死你 鬼脸",
"width": 128, "height": 128, "formats": "png",
},
"": {
"sticker_id": "180", "package_id": "1003", "name": "",
"description": "想睡 倦 打哈欠 睁不开眼 好困啊 sleepy",
"width": 128, "height": 128, "formats": "png",
},
"折磨": {
"sticker_id": "181", "package_id": "1003", "name": "折磨",
"description": "难受 痛苦 煎熬 蚌埠住了 受不了 要命",
"width": 128, "height": 128, "formats": "png",
},
"抠鼻": {
"sticker_id": "182", "package_id": "1003", "name": "抠鼻",
"description": "不屑 无聊 淡定 无所谓 鄙视 挖鼻",
"width": 128, "height": 128, "formats": "png",
},
"鼓掌": {
"sticker_id": "183", "package_id": "1003", "name": "鼓掌",
"description": "拍手 叫好 赞同 666 喝彩 掌声",
"width": 128, "height": 128, "formats": "png",
},
"斜眼笑": {
"sticker_id": "204", "package_id": "1003", "name": "斜眼笑",
"description": "滑稽 坏笑 doge 意味深长 阴阳怪气 嘿嘿嘿",
"width": 128, "height": 128, "formats": "png",
},
"辣眼睛": {
"sticker_id": "216", "package_id": "1003", "name": "辣眼睛",
"description": "看不下去 cringe 毁三观 太丑了 瞎了",
"width": 128, "height": 128, "formats": "png",
},
"哦哟": {
"sticker_id": "217", "package_id": "1003", "name": "哦哟",
"description": "惊讶 起哄 哇哦 有戏 不简单 哟",
"width": 128, "height": 128, "formats": "png",
},
"吃瓜": {
"sticker_id": "222", "package_id": "1003", "name": "吃瓜",
"description": "围观 看戏 八卦 路人 看热闹 板凳",
"width": 128, "height": 128, "formats": "png",
},
"狗头": {
"sticker_id": "225", "package_id": "1003", "name": "狗头",
"description": "doge 保命 开玩笑 滑稽 反讽 懂的都懂",
"width": 128, "height": 128, "formats": "png",
},
"敬礼": {
"sticker_id": "227", "package_id": "1003", "name": "敬礼",
"description": "salute 尊重 收到 遵命 致敬 报告",
"width": 128, "height": 128, "formats": "png",
},
"": {
"sticker_id": "231", "package_id": "1003", "name": "",
"description": "知道了 明白 敷衍 嗯 这样啊 收到",
"width": 128, "height": 128, "formats": "png",
},
"拿到红包": {
"sticker_id": "236", "package_id": "1003", "name": "拿到红包",
"description": "红包 谢谢老板 发财 开心 抢到了 欧气",
"width": 128, "height": 128, "formats": "png",
},
"牛吖": {
"sticker_id": "239", "package_id": "1003", "name": "牛吖",
"description": "牛 厉害 强 666 佩服 大佬",
"width": 128, "height": 128, "formats": "png",
},
"贴贴": {
"sticker_id": "272", "package_id": "1003", "name": "贴贴",
"description": "抱抱 亲昵 蹭蹭 亲密 靠靠 撒娇贴",
"width": 128, "height": 128, "formats": "png",
},
"爱心": {
"sticker_id": "138", "package_id": "1003", "name": "爱心",
"description": "心 love 喜欢你 红心 示爱 么么哒",
"width": 128, "height": 128, "formats": "png",
},
"晚安": {
"sticker_id": "170", "package_id": "1003", "name": "晚安",
"description": "好梦 睡了 night 早点休息 安啦 moon",
"width": 128, "height": 128, "formats": "png",
},
"太阳": {
"sticker_id": "176", "package_id": "1003", "name": "太阳",
"description": "晴天 早上好 阳光 morning 好天气 日",
"width": 128, "height": 128, "formats": "png",
},
"柠檬": {
"sticker_id": "266", "package_id": "1003", "name": "柠檬",
"description": "酸 嫉妒 柠檬精 羡慕 我酸 恰柠檬",
"width": 128, "height": 128, "formats": "png",
},
"大冤种": {
"sticker_id": "267", "package_id": "1003", "name": "大冤种",
"description": "倒霉 吃亏 自嘲 好心没好报 背锅 工具人",
"width": 128, "height": 128, "formats": "png",
},
"吐了": {
"sticker_id": "132", "package_id": "1003", "name": "吐了",
"description": "恶心 yue 受不了 嫌弃 想吐 生理不适",
"width": 128, "height": 128, "formats": "png",
},
"": {
"sticker_id": "134", "package_id": "1003", "name": "",
"description": "生气 愤怒 火大 暴躁 气炸 怼",
"width": 128, "height": 128, "formats": "png",
},
"玫瑰": {
"sticker_id": "165", "package_id": "1003", "name": "玫瑰",
"description": "花 示爱 表白 浪漫 送你花 情人节",
"width": 128, "height": 128, "formats": "png",
},
"凋谢": {
"sticker_id": "119", "package_id": "1003", "name": "凋谢",
"description": "花谢 失恋 难过 枯萎 心碎 凉了",
"width": 128, "height": 128, "formats": "png",
},
"点赞": {
"sticker_id": "159", "package_id": "1003", "name": "点赞",
"description": "赞 认同 好棒 good like 大拇指 顶",
"width": 128, "height": 128, "formats": "png",
},
"握手": {
"sticker_id": "164", "package_id": "1003", "name": "握手",
"description": "合作 你好 商务 hello deal 成交 友好",
"width": 128, "height": 128, "formats": "png",
},
"抱拳": {
"sticker_id": "163", "package_id": "1003", "name": "抱拳",
"description": "谢谢 失敬 江湖 承让 拜托 有礼",
"width": 128, "height": 128, "formats": "png",
},
"ok": {
"sticker_id": "169", "package_id": "1003", "name": "ok",
"description": "好的 收到 没问题 okay 行 可以 懂了",
"width": 128, "height": 128, "formats": "png",
},
"拳头": {
"sticker_id": "174", "package_id": "1003", "name": "拳头",
"description": "加油 干 冲 fight 力量 击拳 硬气",
"width": 128, "height": 128, "formats": "png",
},
"鞭炮": {
"sticker_id": "191", "package_id": "1003", "name": "鞭炮",
"description": "过年 喜庆 爆竹 春节 噼里啪啦 红",
"width": 128, "height": 128, "formats": "png",
},
"烟花": {
"sticker_id": "258", "package_id": "1003", "name": "烟花",
"description": "庆典 漂亮 新年 嘭 绽放 节日快乐",
"width": 128, "height": 128, "formats": "png",
},
}
def get_sticker_by_name(name: str) -> Optional[dict]:
"""
按名称查找贴纸支持模糊匹配
匹配优先级
1. 完全相等name
2. name 包含查询词前缀/子串
3. description 包含查询词同义词搜索
4. 通用模糊评分 sticker-search 同算法命中即返回得分最高的一条
返回 sticker dict找不到返回 None
"""
if not name:
return None
query = name.strip()
if query in STICKER_MAP:
return STICKER_MAP[query]
for key, sticker in STICKER_MAP.items():
if query in key or key in query:
return sticker
for sticker in STICKER_MAP.values():
desc = sticker.get("description", "")
if query in desc:
return sticker
matches = search_stickers(query, limit=1)
return matches[0] if matches else None
def get_random_sticker(category: str = None) -> dict:
"""
随机返回一个贴纸
若指定 category则在 description 中含有该关键词的贴纸里随机选取
category None 时从全表随机
"""
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()))
def get_sticker_by_id(sticker_id: str) -> Optional[dict]:
"""按 sticker_id 精确查找贴纸。"""
if not sticker_id:
return None
sid = str(sticker_id).strip()
for sticker in STICKER_MAP.values():
if sticker.get("sticker_id") == sid:
return sticker
return None
# ---------------------------------------------------------------------------
# 模糊搜索(对齐 chatbot-web yuanbao-openclaw-plugin/sticker-cache.ts.searchStickers
# ---------------------------------------------------------------------------
_PUNCT_RE = re.compile(r"[\s\u3000\-_·.,,。!?\"“”'‘’、/\\]+")
def _normalize_text(raw: str) -> str:
return unicodedata.normalize("NFKC", str(raw or "")).strip().lower()
def _compact_text(raw: str) -> str:
return _PUNCT_RE.sub("", _normalize_text(raw))
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:
n = bag.get(ch, 0)
if n > 0:
hits += 1
bag[ch] = n - 1
return hits / len(needle)
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
def _longest_subsequence_ratio(needle: str, haystack: str) -> float:
if not needle:
return 0.0
j = 0
for ch in haystack:
if j >= len(needle):
break
if ch == needle[j]:
j += 1
return j / len(needle)
def _score_field(haystack: str, query: str) -> float:
hay = _normalize_text(haystack)
q = _normalize_text(query)
if not hay or not q:
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
def search_stickers(query: str, limit: int = 10) -> list[dict]:
"""
在内置贴纸表中按模糊匹配排序返回前 N 条结果
评分综合 name/description 字段的子串字符多重集覆盖bigram Jaccard子序列比例
name 权重略高于 description×0.88 query 时按字典顺序返回前 N
"""
safe_limit = max(1, min(500, int(limit) if limit else 10))
if not query or not _normalize_text(query):
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 = str(sticker.get("sticker_id", "")).strip()
q_norm = _normalize_text(query)
id_s = 0.0
if sid and q_norm:
sid_norm = _normalize_text(sid)
if sid_norm == q_norm:
id_s = 100.0
elif q_norm in sid_norm:
id_s = 84.0
scored.append((max(name_s, desc_s, 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)
filtered = [pair for pair in scored if pair[0] >= floor]
out = filtered if filtered else scored
return [s for _, s in out[:safe_limit]]
def build_face_msg_body(
face_index: int,
face_type: int = 1,
data: Optional[str] = None,
) -> list:
"""
构造 TIMFaceElem 消息体
Yuanbao 约定
- index 固定传 0服务端通过 data 字段识别具体表情
- data JSON 字符串包含 sticker_id / package_id 等字段
Args:
face_index: 保留字段暂时不影响 wire formatYuanbao 固定 index=0
face_index > 0 时视为旧版 QQ 表情 ID直接放入 index
face_type: 保留字段兼容旧接口当前未使用
data: 已序列化的 JSON 字符串 None 时仅传 index
Returns:
符合 Yuanbao TIM 协议的 msg_body list::
[{"msg_type": "TIMFaceElem", "msg_content": {"index": 0, "data": "..."}}]
"""
msg_content: dict = {"index": face_index}
if data is not None:
msg_content["data"] = data
return [{"msg_type": "TIMFaceElem", "msg_content": msg_content}]
def build_sticker_msg_body(sticker: dict) -> list:
"""
STICKER_MAP 中的 sticker dict 直接构造 TIMFaceElem 消息体
这是 send_sticker() 的内部辅助确保 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"],
},
ensure_ascii=False,
separators=(",", ":"),
)
return build_face_msg_body(face_index=0, data=data_payload)