coding-bridge 2026.7.3.2__tar.gz → 2026.7.3.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/PKG-INFO +1 -1
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/channels/__init__.py +3 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/channels/config.py +92 -2
- coding_bridge-2026.7.3.4/coding_bridge/channels/policy.py +230 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/client.py +37 -0
- coding_bridge-2026.7.3.4/coding_bridge/channels_cli.py +368 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/cli.py +11 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/pyproject.toml +1 -1
- coding_bridge-2026.7.3.4/tests/test_channels_cli.py +309 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_channels_config.py +137 -0
- coding_bridge-2026.7.3.4/tests/test_channels_policy.py +473 -0
- coding_bridge-2026.7.3.4/tests/test_wechat_task_status.py +124 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/.env.example +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/.github/workflows/ci.yaml +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/.github/workflows/publish.yml +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/.gitignore +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/LICENSE +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/NOTICE +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/README.md +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/__init__.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/__main__.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/attachments.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/capabilities.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/channels/base.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/channels/dispatcher.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/__init__.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/adapter.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/config.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/connection.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/fs.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/history.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/images.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/locking.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/logs.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/pairing.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/permissions.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/protocol.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/providers/__init__.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/providers/base.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/providers/claude.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/providers/codex.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/providers/copilot.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/session.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/session_meta.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/store.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/docs/design/reliable-event-delivery.md +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/conftest.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_ask_user_question.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_attachments.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_capabilities.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_channels_base.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_channels_dispatcher.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_channels_wechat.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_codex.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_config.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_connection.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_copilot.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_edit_fork.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_fs.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_history.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_images.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_locking.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_outbox.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_pairing.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_permissions.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_protocol.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_resume_guard.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_session.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_session_meta.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_slash_commands.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_store.py +0 -0
- {coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/tests/test_streaming.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coding-bridge
|
|
3
|
-
Version: 2026.7.3.
|
|
3
|
+
Version: 2026.7.3.4
|
|
4
4
|
Summary: Node daemon for AceDataCloud Coding Bridge — run Claude Code on your own machine and drive it from the web.
|
|
5
5
|
Project-URL: Homepage, https://github.com/AceDataCloud/CodingBridge
|
|
6
6
|
Project-URL: Issues, https://github.com/AceDataCloud/CodingBridge/issues
|
|
@@ -25,14 +25,17 @@ from .config import (
|
|
|
25
25
|
parse_channels_config,
|
|
26
26
|
)
|
|
27
27
|
from .dispatcher import SessionDispatcher
|
|
28
|
+
from .policy import ChannelPolicy, PolicyGate
|
|
28
29
|
|
|
29
30
|
__all__ = [
|
|
30
31
|
"ChannelAdapter",
|
|
32
|
+
"ChannelPolicy",
|
|
31
33
|
"ChannelTarget",
|
|
32
34
|
"ChannelsConfig",
|
|
33
35
|
"ConfigError",
|
|
34
36
|
"IncomingMessage",
|
|
35
37
|
"MessageHandler",
|
|
38
|
+
"PolicyGate",
|
|
36
39
|
"SendResult",
|
|
37
40
|
"SessionDispatcher",
|
|
38
41
|
"WeChatInstanceConfig",
|
|
@@ -37,19 +37,34 @@ Design notes:
|
|
|
37
37
|
|
|
38
38
|
from __future__ import annotations
|
|
39
39
|
|
|
40
|
+
import math
|
|
40
41
|
import os
|
|
41
42
|
from dataclasses import dataclass, field
|
|
42
43
|
from pathlib import Path
|
|
43
|
-
from typing import Any
|
|
44
|
+
from typing import TYPE_CHECKING, Any
|
|
44
45
|
from urllib.parse import urlparse
|
|
45
46
|
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from .policy import ChannelPolicy
|
|
49
|
+
|
|
46
50
|
try:
|
|
47
51
|
import tomllib as _toml
|
|
48
52
|
except ModuleNotFoundError: # Python 3.10
|
|
49
53
|
import tomli as _toml # type: ignore[import-not-found]
|
|
50
54
|
|
|
51
55
|
_WECHAT_KEYS = frozenset(
|
|
52
|
-
{
|
|
56
|
+
{
|
|
57
|
+
"instance_id",
|
|
58
|
+
"base_url",
|
|
59
|
+
"token_env",
|
|
60
|
+
"token_file",
|
|
61
|
+
"enabled",
|
|
62
|
+
"default_provider",
|
|
63
|
+
"trigger_prefix",
|
|
64
|
+
"allowed_senders",
|
|
65
|
+
"rate_limit_per_min",
|
|
66
|
+
"dedup_window_seconds",
|
|
67
|
+
}
|
|
53
68
|
)
|
|
54
69
|
_CHANNELS_KEYS = frozenset({"wechat"})
|
|
55
70
|
|
|
@@ -76,6 +91,33 @@ class WeChatInstanceConfig:
|
|
|
76
91
|
token_file: str | None = None
|
|
77
92
|
enabled: bool = False
|
|
78
93
|
default_provider: str | None = None
|
|
94
|
+
#: Message text must start with this to be forwarded. Empty string disables
|
|
95
|
+
#: the check. See ``coding_bridge.channels.policy.ChannelPolicy``.
|
|
96
|
+
trigger_prefix: str = "/ask "
|
|
97
|
+
#: Sender allowlist. Empty tuple = allow all (still gated by the WeChat
|
|
98
|
+
#: gateway token). Stored as tuple because ``WeChatInstanceConfig`` is frozen.
|
|
99
|
+
allowed_senders: tuple[str, ...] = ()
|
|
100
|
+
#: Sliding-window rate limit per sender_id over the last 60 s.
|
|
101
|
+
rate_limit_per_min: int = 6
|
|
102
|
+
#: Dedup window for repeat upstream ``msg_id`` (upstream retries). ``0``
|
|
103
|
+
#: disables dedup.
|
|
104
|
+
dedup_window_seconds: float = 300.0
|
|
105
|
+
|
|
106
|
+
def to_policy(self) -> ChannelPolicy:
|
|
107
|
+
"""Build the runtime ``ChannelPolicy`` this instance describes."""
|
|
108
|
+
# Local import to avoid a circular module cycle
|
|
109
|
+
# (policy → base → nothing config-specific; config → policy would loop
|
|
110
|
+
# only at type-annotation time, but keep the runtime import lazy so
|
|
111
|
+
# ``coding_bridge.channels.config`` alone still loads without importing
|
|
112
|
+
# the whole channels stack).
|
|
113
|
+
from .policy import ChannelPolicy
|
|
114
|
+
|
|
115
|
+
return ChannelPolicy(
|
|
116
|
+
trigger_prefix=self.trigger_prefix,
|
|
117
|
+
allowed_senders=self.allowed_senders,
|
|
118
|
+
rate_limit_per_min=self.rate_limit_per_min,
|
|
119
|
+
dedup_window_seconds=self.dedup_window_seconds,
|
|
120
|
+
)
|
|
79
121
|
|
|
80
122
|
def resolve_token(self, environ: dict[str, str] | None = None) -> str:
|
|
81
123
|
"""Load the token from env var or secrets file. Never logs it.
|
|
@@ -198,6 +240,50 @@ def _parse_wechat(block: dict[str, Any], index: int) -> WeChatInstanceConfig:
|
|
|
198
240
|
raise ConfigError(
|
|
199
241
|
f"[[channels.wechat]] {instance_id!r}: default_provider must be a string"
|
|
200
242
|
)
|
|
243
|
+
|
|
244
|
+
trigger_prefix = block.get("trigger_prefix", "/ask ")
|
|
245
|
+
if not isinstance(trigger_prefix, str):
|
|
246
|
+
raise ConfigError(
|
|
247
|
+
f"[[channels.wechat]] {instance_id!r}: trigger_prefix must be a string"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
allowed_senders_raw = block.get("allowed_senders", [])
|
|
251
|
+
if not isinstance(allowed_senders_raw, list) or not all(
|
|
252
|
+
isinstance(x, str) and x for x in allowed_senders_raw
|
|
253
|
+
):
|
|
254
|
+
raise ConfigError(
|
|
255
|
+
f"[[channels.wechat]] {instance_id!r}: allowed_senders must be a "
|
|
256
|
+
"list of non-empty strings"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
rate_limit = block.get("rate_limit_per_min", 6)
|
|
260
|
+
# Bool is a subclass of int in Python — reject explicit bools to catch
|
|
261
|
+
# `rate_limit_per_min = true` typos.
|
|
262
|
+
if isinstance(rate_limit, bool) or not isinstance(rate_limit, int) or rate_limit < 0:
|
|
263
|
+
raise ConfigError(
|
|
264
|
+
f"[[channels.wechat]] {instance_id!r}: rate_limit_per_min must be "
|
|
265
|
+
"a non-negative int"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
dedup_window = block.get("dedup_window_seconds", 300.0)
|
|
269
|
+
if isinstance(dedup_window, bool) or not isinstance(dedup_window, (int, float)):
|
|
270
|
+
raise ConfigError(
|
|
271
|
+
f"[[channels.wechat]] {instance_id!r}: dedup_window_seconds must "
|
|
272
|
+
"be a number"
|
|
273
|
+
)
|
|
274
|
+
# NaN < 0 is False so a plain range check misses it — reject explicitly
|
|
275
|
+
# so ``_prune_dedup`` never sees a non-comparable value.
|
|
276
|
+
if math.isnan(dedup_window) or math.isinf(dedup_window):
|
|
277
|
+
raise ConfigError(
|
|
278
|
+
f"[[channels.wechat]] {instance_id!r}: dedup_window_seconds must "
|
|
279
|
+
"be finite"
|
|
280
|
+
)
|
|
281
|
+
if dedup_window < 0:
|
|
282
|
+
raise ConfigError(
|
|
283
|
+
f"[[channels.wechat]] {instance_id!r}: dedup_window_seconds must "
|
|
284
|
+
"be >= 0"
|
|
285
|
+
)
|
|
286
|
+
|
|
201
287
|
return WeChatInstanceConfig(
|
|
202
288
|
instance_id=instance_id,
|
|
203
289
|
base_url=base_url.rstrip("/"),
|
|
@@ -205,6 +291,10 @@ def _parse_wechat(block: dict[str, Any], index: int) -> WeChatInstanceConfig:
|
|
|
205
291
|
token_file=token_file,
|
|
206
292
|
enabled=enabled,
|
|
207
293
|
default_provider=default_provider,
|
|
294
|
+
trigger_prefix=trigger_prefix,
|
|
295
|
+
allowed_senders=tuple(allowed_senders_raw),
|
|
296
|
+
rate_limit_per_min=rate_limit,
|
|
297
|
+
dedup_window_seconds=float(dedup_window),
|
|
208
298
|
)
|
|
209
299
|
|
|
210
300
|
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Per-instance abuse-control policy for channel adapters.
|
|
2
|
+
|
|
3
|
+
Wraps the ``MessageHandler`` you pass to ``ChannelAdapter.set_handler``. The
|
|
4
|
+
adapter has no idea abuse controls exist; the wrapper enforces them **before**
|
|
5
|
+
the message ever reaches the ``SessionDispatcher``.
|
|
6
|
+
|
|
7
|
+
Enforced (in this order, cheapest to most-expensive):
|
|
8
|
+
|
|
9
|
+
1. **Trigger prefix** — the raw message text must start with a configured
|
|
10
|
+
prefix (default ``"/ask "``) so a bot in a group chat only responds when
|
|
11
|
+
explicitly addressed. Prefix is stripped before the message is forwarded.
|
|
12
|
+
Empty string → prefix check disabled.
|
|
13
|
+
2. **Sender allowlist** — if ``allowed_senders`` is non-empty, only those
|
|
14
|
+
``sender_id`` values are accepted. Empty list → allow all (rely on
|
|
15
|
+
``token`` for auth; useful when only the account owner can reach the
|
|
16
|
+
WeChat gateway endpoint anyway).
|
|
17
|
+
3. **Per-sender rate limit** — sliding-window: at most ``rate_limit_per_min``
|
|
18
|
+
messages from one ``sender_id`` in the last 60 s. Default 6/min.
|
|
19
|
+
|
|
20
|
+
Every rejection is logged at INFO with a stable ``reason`` code so operators
|
|
21
|
+
can grep. Nothing sensitive (token, message body) ever appears in the log.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from collections import deque
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from dataclasses import dataclass, field, replace
|
|
32
|
+
|
|
33
|
+
from .base import ChannelAdapter, IncomingMessage, MessageHandler
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("coding-bridge.channels")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ChannelPolicy:
|
|
40
|
+
"""Per-instance abuse-control knobs. Frozen — build once at start-up."""
|
|
41
|
+
|
|
42
|
+
#: Only accept messages whose text starts with this string (default
|
|
43
|
+
#: ``"/ask "``). Empty string disables the prefix check.
|
|
44
|
+
trigger_prefix: str = "/ask "
|
|
45
|
+
|
|
46
|
+
#: Sender IDs that may talk to this bot. Empty tuple = allow all.
|
|
47
|
+
allowed_senders: tuple[str, ...] = ()
|
|
48
|
+
|
|
49
|
+
#: Per-sender sliding window: at most this many messages per 60 s.
|
|
50
|
+
rate_limit_per_min: int = 6
|
|
51
|
+
|
|
52
|
+
#: Dedup window in seconds. Repeat upstream ``msg_id`` inside this window
|
|
53
|
+
#: is silently dropped (upstream retries, duplicate deliveries). ``0``
|
|
54
|
+
#: disables dedup — useful in tests.
|
|
55
|
+
dedup_window_seconds: float = 300.0
|
|
56
|
+
|
|
57
|
+
#: How many recent ``msg_id`` values to remember per adapter instance.
|
|
58
|
+
dedup_max_ids: int = 1024
|
|
59
|
+
|
|
60
|
+
#: Cap on how many distinct ``sender_id`` rate-limit windows we keep in
|
|
61
|
+
#: memory. Beyond this the least-recently-active bucket is dropped so an
|
|
62
|
+
#: attacker spamming with random sender IDs can't grow the map without
|
|
63
|
+
#: bound. Real deployments serve at most a few hundred distinct senders.
|
|
64
|
+
rate_limit_max_senders: int = 4096
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class _SlidingWindow:
|
|
69
|
+
"""Per-sender timestamps for the last minute."""
|
|
70
|
+
|
|
71
|
+
window_seconds: float
|
|
72
|
+
limit: int
|
|
73
|
+
stamps: deque[float] = field(default_factory=deque)
|
|
74
|
+
last_touch: float = 0.0
|
|
75
|
+
|
|
76
|
+
def allow(self, now: float) -> bool:
|
|
77
|
+
self.last_touch = now
|
|
78
|
+
cutoff = now - self.window_seconds
|
|
79
|
+
while self.stamps and self.stamps[0] < cutoff:
|
|
80
|
+
self.stamps.popleft()
|
|
81
|
+
if len(self.stamps) >= self.limit:
|
|
82
|
+
return False
|
|
83
|
+
self.stamps.append(now)
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PolicyGate:
|
|
88
|
+
"""Wraps a downstream ``MessageHandler`` with the policy checks above.
|
|
89
|
+
|
|
90
|
+
Usage::
|
|
91
|
+
|
|
92
|
+
gate = PolicyGate(policy, downstream=dispatcher.handle_message)
|
|
93
|
+
adapter.set_handler(gate.handle)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
policy: ChannelPolicy,
|
|
99
|
+
downstream: MessageHandler,
|
|
100
|
+
*,
|
|
101
|
+
clock: Callable[[], float] = time.monotonic,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._policy = policy
|
|
104
|
+
self._downstream = downstream
|
|
105
|
+
self._clock = clock
|
|
106
|
+
self._lock = asyncio.Lock()
|
|
107
|
+
self._windows: dict[str, _SlidingWindow] = {}
|
|
108
|
+
# dedup: ordered dict of msg_id → timestamp (arrival). We use a deque
|
|
109
|
+
# for insertion order + dict for O(1) membership. Bounded by
|
|
110
|
+
# ``dedup_max_ids``; oldest evicted first.
|
|
111
|
+
self._dedup_order: deque[str] = deque()
|
|
112
|
+
self._dedup_seen: dict[str, float] = {}
|
|
113
|
+
|
|
114
|
+
async def handle(self, msg: IncomingMessage, adapter: ChannelAdapter) -> None:
|
|
115
|
+
"""Run all policy checks, then delegate. Never raises."""
|
|
116
|
+
# (1) trigger prefix
|
|
117
|
+
prefix = self._policy.trigger_prefix
|
|
118
|
+
text = msg.text
|
|
119
|
+
if prefix:
|
|
120
|
+
if not text.startswith(prefix):
|
|
121
|
+
self._reject(adapter, msg, "trigger_prefix_missing")
|
|
122
|
+
return
|
|
123
|
+
text = text[len(prefix) :].lstrip()
|
|
124
|
+
if not text:
|
|
125
|
+
self._reject(adapter, msg, "empty_after_prefix")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# (2) sender allowlist
|
|
129
|
+
allowed = self._policy.allowed_senders
|
|
130
|
+
if allowed and msg.sender_id not in allowed:
|
|
131
|
+
self._reject(adapter, msg, "sender_not_allowed")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# (3+4) rate limit + dedup share the lock so a burst can't race
|
|
135
|
+
async with self._lock:
|
|
136
|
+
# dedup
|
|
137
|
+
if self._policy.dedup_window_seconds > 0 and msg.upstream_id:
|
|
138
|
+
now = self._clock()
|
|
139
|
+
self._prune_dedup(now)
|
|
140
|
+
if msg.upstream_id in self._dedup_seen:
|
|
141
|
+
self._reject(adapter, msg, "duplicate_msg_id")
|
|
142
|
+
return
|
|
143
|
+
self._remember(msg.upstream_id, now)
|
|
144
|
+
|
|
145
|
+
# rate limit
|
|
146
|
+
if self._policy.rate_limit_per_min > 0:
|
|
147
|
+
now = self._clock()
|
|
148
|
+
self._evict_stale_windows(now)
|
|
149
|
+
window = self._windows.get(msg.sender_id)
|
|
150
|
+
if window is None:
|
|
151
|
+
# Cap-check BEFORE insert so an attacker spamming with
|
|
152
|
+
# random sender IDs can never grow the map past the cap.
|
|
153
|
+
if len(self._windows) >= self._policy.rate_limit_max_senders:
|
|
154
|
+
self._drop_lru_window()
|
|
155
|
+
window = _SlidingWindow(
|
|
156
|
+
window_seconds=60.0, limit=self._policy.rate_limit_per_min
|
|
157
|
+
)
|
|
158
|
+
self._windows[msg.sender_id] = window
|
|
159
|
+
if not window.allow(now):
|
|
160
|
+
self._reject(adapter, msg, "rate_limited")
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
# (5) forward to dispatcher — strip prefix out of the payload
|
|
164
|
+
forwarded = replace(msg, text=text)
|
|
165
|
+
try:
|
|
166
|
+
await self._downstream(forwarded, adapter)
|
|
167
|
+
except Exception as exc: # dispatcher errors must not kill the adapter
|
|
168
|
+
# NOTE: intentionally `logger.error`, NOT `logger.exception`, so
|
|
169
|
+
# the traceback (which may include `repr(exc)` with the original
|
|
170
|
+
# message text embedded) never reaches the log. The exception
|
|
171
|
+
# class name is enough for operators to grep.
|
|
172
|
+
logger.error(
|
|
173
|
+
"downstream handler raised: adapter=%s instance=%s exc=%s",
|
|
174
|
+
adapter.name,
|
|
175
|
+
adapter.instance_id,
|
|
176
|
+
exc.__class__.__name__,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _reject(self, adapter: ChannelAdapter, msg: IncomingMessage, reason: str) -> None:
|
|
180
|
+
# NOTE: never log ``msg.text`` (may contain user content or secrets that
|
|
181
|
+
# look like commands). Log the sender + reason only.
|
|
182
|
+
logger.info(
|
|
183
|
+
"channel message rejected: adapter=%s instance=%s sender=%s reason=%s",
|
|
184
|
+
adapter.name,
|
|
185
|
+
adapter.instance_id,
|
|
186
|
+
msg.sender_id,
|
|
187
|
+
reason,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _prune_dedup(self, now: float) -> None:
|
|
191
|
+
cutoff = now - self._policy.dedup_window_seconds
|
|
192
|
+
while self._dedup_order and self._dedup_seen.get(self._dedup_order[0], 0) < cutoff:
|
|
193
|
+
oldest = self._dedup_order.popleft()
|
|
194
|
+
self._dedup_seen.pop(oldest, None)
|
|
195
|
+
|
|
196
|
+
def _remember(self, msg_id: str, now: float) -> None:
|
|
197
|
+
# Cap: evict oldest until size is under ``dedup_max_ids``.
|
|
198
|
+
while len(self._dedup_order) >= self._policy.dedup_max_ids:
|
|
199
|
+
oldest = self._dedup_order.popleft()
|
|
200
|
+
self._dedup_seen.pop(oldest, None)
|
|
201
|
+
self._dedup_order.append(msg_id)
|
|
202
|
+
self._dedup_seen[msg_id] = now
|
|
203
|
+
|
|
204
|
+
def _evict_stale_windows(self, now: float) -> None:
|
|
205
|
+
"""Drop rate-limit windows that haven't fired in 2 × window_seconds.
|
|
206
|
+
|
|
207
|
+
Called on the hot path (inside ``self._lock``) so it must be cheap.
|
|
208
|
+
Windows are ~120 s stale — well past their rate-limit window — so
|
|
209
|
+
removing them can't affect a genuine sender's counter.
|
|
210
|
+
"""
|
|
211
|
+
stale_cutoff = now - 120.0
|
|
212
|
+
stale = [k for k, w in self._windows.items() if w.last_touch < stale_cutoff]
|
|
213
|
+
for k in stale:
|
|
214
|
+
self._windows.pop(k, None)
|
|
215
|
+
|
|
216
|
+
def _drop_lru_window(self) -> None:
|
|
217
|
+
"""Evict the least-recently-touched sender window.
|
|
218
|
+
|
|
219
|
+
Fires only when the map is at its ``rate_limit_max_senders`` cap AND
|
|
220
|
+
no idle-eviction cleared enough room. Cost is O(N) over the map, but
|
|
221
|
+
N is capped and this path fires at most once per unique-sender
|
|
222
|
+
insertion above the cap.
|
|
223
|
+
"""
|
|
224
|
+
if not self._windows:
|
|
225
|
+
return
|
|
226
|
+
lru_key = min(self._windows, key=lambda k: self._windows[k].last_touch)
|
|
227
|
+
self._windows.pop(lru_key, None)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
__all__ = ["ChannelPolicy", "PolicyGate"]
|
{coding_bridge-2026.7.3.2 → coding_bridge-2026.7.3.4}/coding_bridge/channels/wechat/client.py
RENAMED
|
@@ -7,8 +7,10 @@ so tests can inject a transport via :func:`respx`.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import re
|
|
10
11
|
import time
|
|
11
12
|
from typing import Any
|
|
13
|
+
from urllib.parse import quote
|
|
12
14
|
|
|
13
15
|
import httpx
|
|
14
16
|
|
|
@@ -19,6 +21,11 @@ from ..base import ChannelTarget, SendResult
|
|
|
19
21
|
# to the caller so operators have real diagnostics in the log.
|
|
20
22
|
_ACCEPTED_STATUSES = frozenset({200, 201, 202})
|
|
21
23
|
|
|
24
|
+
# Safe pattern for a gateway task id. The gateway generates UUID-like tokens; we
|
|
25
|
+
# refuse anything with URL metacharacters so a caller can't inject a query
|
|
26
|
+
# parameter or path segment into the GET.
|
|
27
|
+
_TASK_ID_RE = re.compile(r"^[A-Za-z0-9_\-]{1,128}$")
|
|
28
|
+
|
|
22
29
|
|
|
23
30
|
class WeChatClient:
|
|
24
31
|
"""Async client for one WeChat gateway endpoint (one CVM / one WeChat instance)."""
|
|
@@ -104,3 +111,33 @@ class WeChatClient:
|
|
|
104
111
|
error=f"HTTP {resp.status_code}: {(err or '')[:200]}",
|
|
105
112
|
latency_ms=latency_ms,
|
|
106
113
|
)
|
|
114
|
+
|
|
115
|
+
async def get_task_status(self, task_id: str) -> dict[str, Any]:
|
|
116
|
+
"""Fetch delivery status for a task returned by ``send_message``.
|
|
117
|
+
|
|
118
|
+
Returns the parsed JSON body as-is (the gateway's shape may evolve). Raises
|
|
119
|
+
``httpx.HTTPStatusError`` on non-2xx so the caller can distinguish
|
|
120
|
+
"not delivered yet" (200 with status=queued) from "task unknown" (404).
|
|
121
|
+
|
|
122
|
+
Kept deliberately small: this is a debug / diagnostics primitive used
|
|
123
|
+
by the ``doctor`` CLI command (P4) and E2E validation, not by the
|
|
124
|
+
adapter's fast path.
|
|
125
|
+
|
|
126
|
+
Raises ``ValueError`` on an empty or unsafely-shaped ``task_id`` —
|
|
127
|
+
rather than URL-encode and pray, we refuse anything that could
|
|
128
|
+
contain path/query metacharacters (``?``, ``#``, ``/``, ``..``).
|
|
129
|
+
"""
|
|
130
|
+
if not task_id:
|
|
131
|
+
raise ValueError("task_id must not be empty")
|
|
132
|
+
if not _TASK_ID_RE.fullmatch(task_id):
|
|
133
|
+
raise ValueError("task_id contains invalid characters")
|
|
134
|
+
# Belt-and-suspenders: `quote(safe="")` still produces a plain
|
|
135
|
+
# segment since the regex already rejects unsafe input.
|
|
136
|
+
path = f"/api/messages/tasks/{quote(task_id, safe='')}"
|
|
137
|
+
resp = await self._client.get(path)
|
|
138
|
+
resp.raise_for_status()
|
|
139
|
+
try:
|
|
140
|
+
body = resp.json()
|
|
141
|
+
except ValueError:
|
|
142
|
+
return {}
|
|
143
|
+
return body if isinstance(body, dict) else {}
|