coding-bridge 2026.7.3.0__tar.gz → 2026.7.3.2__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.0 → coding_bridge-2026.7.3.2}/PKG-INFO +3 -1
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/channels/__init__.py +12 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/channels/base.py +6 -6
- coding_bridge-2026.7.3.2/coding_bridge/channels/config.py +282 -0
- coding_bridge-2026.7.3.2/coding_bridge/channels/wechat/__init__.py +26 -0
- coding_bridge-2026.7.3.2/coding_bridge/channels/wechat/adapter.py +222 -0
- coding_bridge-2026.7.3.2/coding_bridge/channels/wechat/client.py +106 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/config.py +5 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/pyproject.toml +6 -1
- coding_bridge-2026.7.3.2/tests/test_channels_config.py +384 -0
- coding_bridge-2026.7.3.2/tests/test_channels_wechat.py +502 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/.env.example +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/.github/workflows/ci.yaml +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/.github/workflows/publish.yml +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/.gitignore +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/LICENSE +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/NOTICE +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/README.md +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/__init__.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/__main__.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/attachments.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/capabilities.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/channels/dispatcher.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/cli.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/connection.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/fs.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/history.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/images.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/locking.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/logs.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/pairing.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/permissions.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/protocol.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/providers/__init__.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/providers/base.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/providers/claude.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/providers/codex.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/providers/copilot.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/session.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/session_meta.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/store.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/docs/design/reliable-event-delivery.md +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/conftest.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_ask_user_question.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_attachments.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_capabilities.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_channels_base.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_channels_dispatcher.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_codex.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_config.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_connection.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_copilot.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_edit_fork.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_fs.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_history.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_images.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_locking.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_outbox.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_pairing.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_permissions.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_protocol.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_resume_guard.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_session.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_session_meta.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_slash_commands.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_store.py +0 -0
- {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/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.2
|
|
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
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
16
16
|
Requires-Python: >=3.10
|
|
17
17
|
Requires-Dist: claude-agent-sdk>=0.1.0
|
|
18
18
|
Requires-Dist: httpx<0.29,>=0.27
|
|
19
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
19
20
|
Requires-Dist: websockets<14,>=12
|
|
20
21
|
Provides-Extra: dev
|
|
21
22
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
@@ -25,6 +26,7 @@ Requires-Dist: respx>=0.21; extra == 'dev'
|
|
|
25
26
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
26
27
|
Provides-Extra: qr
|
|
27
28
|
Requires-Dist: qrcode>=7.4; extra == 'qr'
|
|
29
|
+
Provides-Extra: wechat
|
|
28
30
|
Description-Content-Type: text/markdown
|
|
29
31
|
|
|
30
32
|
# Coding Bridge Agent
|
|
@@ -17,13 +17,25 @@ from .base import (
|
|
|
17
17
|
MessageHandler,
|
|
18
18
|
SendResult,
|
|
19
19
|
)
|
|
20
|
+
from .config import (
|
|
21
|
+
ChannelsConfig,
|
|
22
|
+
ConfigError,
|
|
23
|
+
WeChatInstanceConfig,
|
|
24
|
+
load_channels_config,
|
|
25
|
+
parse_channels_config,
|
|
26
|
+
)
|
|
20
27
|
from .dispatcher import SessionDispatcher
|
|
21
28
|
|
|
22
29
|
__all__ = [
|
|
23
30
|
"ChannelAdapter",
|
|
24
31
|
"ChannelTarget",
|
|
32
|
+
"ChannelsConfig",
|
|
33
|
+
"ConfigError",
|
|
25
34
|
"IncomingMessage",
|
|
26
35
|
"MessageHandler",
|
|
27
36
|
"SendResult",
|
|
28
37
|
"SessionDispatcher",
|
|
38
|
+
"WeChatInstanceConfig",
|
|
39
|
+
"load_channels_config",
|
|
40
|
+
"parse_channels_config",
|
|
29
41
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Channel-adapter Protocol and value objects shared by every messenger.
|
|
2
2
|
|
|
3
|
-
An adapter is the thin glue between one external messaging system (WeChat
|
|
4
|
-
|
|
3
|
+
An adapter is the thin glue between one external messaging system (WeChat,
|
|
4
|
+
later Telegram / Discord …) and the dispatcher. It never spawns provider
|
|
5
5
|
processes, tracks sessions, or evaluates prompts — those live in
|
|
6
6
|
``SessionDispatcher`` and reuse the existing ``Session`` / ``Provider`` machinery
|
|
7
7
|
so the browser and WeChat paths share one coding-agent implementation.
|
|
@@ -9,7 +9,7 @@ so the browser and WeChat paths share one coding-agent implementation.
|
|
|
9
9
|
Design invariants:
|
|
10
10
|
|
|
11
11
|
* ``instance_id`` is opaque to the dispatcher but MUST be stable per adapter
|
|
12
|
-
instance (per configured
|
|
12
|
+
instance (per configured WeChat gateway endpoint, per Bot token, …) so multi-instance
|
|
13
13
|
deployments can namespace dedup keys, log fields, and outbox rows.
|
|
14
14
|
* ``send()`` is expected to return a ``SendResult`` — never raise on a delivery
|
|
15
15
|
failure — because delivery is best-effort and the dispatcher records the
|
|
@@ -29,8 +29,8 @@ class ChannelTarget:
|
|
|
29
29
|
|
|
30
30
|
``conversation_id`` is the ID of the "room" — a WeChat private chat wxid, a
|
|
31
31
|
group room id, a Telegram chat_id, etc. ``reply_to_id`` is set only when the
|
|
32
|
-
external protocol supports a reply-to primitive worth carrying (WeChat
|
|
33
|
-
|
|
32
|
+
external protocol supports a reply-to primitive worth carrying (the WeChat
|
|
33
|
+
gateway's ``msg_id``, Telegram's ``reply_to_message_id``); adapters that lack
|
|
34
34
|
one leave it ``None``.
|
|
35
35
|
"""
|
|
36
36
|
|
|
@@ -54,7 +54,7 @@ class IncomingMessage:
|
|
|
54
54
|
target: ChannelTarget
|
|
55
55
|
text: str
|
|
56
56
|
msg_type: str
|
|
57
|
-
direction: str # ``inbound`` / ``outbound`` (per
|
|
57
|
+
direction: str # ``inbound`` / ``outbound`` (per the gateway's schema)
|
|
58
58
|
upstream_id: str | None = None
|
|
59
59
|
received_at_ms: int | None = None
|
|
60
60
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Channels config schema — parses ``~/.ace-bridge/channels.toml``.
|
|
2
|
+
|
|
3
|
+
Split from ``coding_bridge.config`` on purpose: channels are optional and evolve
|
|
4
|
+
independently of the core node settings. Users who never touch channels never
|
|
5
|
+
load this module.
|
|
6
|
+
|
|
7
|
+
Schema (fail-fast; unknown keys raise):
|
|
8
|
+
|
|
9
|
+
.. code-block:: toml
|
|
10
|
+
|
|
11
|
+
[channels]
|
|
12
|
+
# (reserved for future globals)
|
|
13
|
+
|
|
14
|
+
[[channels.wechat]]
|
|
15
|
+
instance_id = "beijing-cvm" # required, unique per file
|
|
16
|
+
base_url = "http://82.156.126.14:8000" # required, http:// or https://
|
|
17
|
+
token_env = "WECHAT_TOKEN_BEIJING" # required unless token_file is set
|
|
18
|
+
token_file = "/run/secrets/beijing" # optional alternative to token_env
|
|
19
|
+
enabled = false # default false — explicit opt-in
|
|
20
|
+
default_provider = "claude" # optional override for this instance
|
|
21
|
+
|
|
22
|
+
Design notes:
|
|
23
|
+
|
|
24
|
+
* The **token itself never lives in the TOML file** — the file references an
|
|
25
|
+
env var name (`token_env`) or a secrets-file path (`token_file`). This
|
|
26
|
+
matches the deployment templates in P8 (systemd EnvironmentFile,
|
|
27
|
+
launchd, Windows service credentials store).
|
|
28
|
+
* `enabled = false` is the default so accidentally shipping a `channels.toml`
|
|
29
|
+
never puts an unsupervised bot online.
|
|
30
|
+
* Unknown keys raise — a typo like `intance_id` or `allowlist_typo` should
|
|
31
|
+
fail loudly at boot, not silently degrade to unsafe defaults.
|
|
32
|
+
* A hard 1 MB cap on the TOML file guards against accidentally pointing at
|
|
33
|
+
``/dev/urandom`` or a runaway log.
|
|
34
|
+
* ``base_url`` may not contain embedded userinfo (``http://user:pass@host``);
|
|
35
|
+
credentials go through ``token_env``/``token_file`` only.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import os
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
from urllib.parse import urlparse
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
import tomllib as _toml
|
|
48
|
+
except ModuleNotFoundError: # Python 3.10
|
|
49
|
+
import tomli as _toml # type: ignore[import-not-found]
|
|
50
|
+
|
|
51
|
+
_WECHAT_KEYS = frozenset(
|
|
52
|
+
{"instance_id", "base_url", "token_env", "token_file", "enabled", "default_provider"}
|
|
53
|
+
)
|
|
54
|
+
_CHANNELS_KEYS = frozenset({"wechat"})
|
|
55
|
+
|
|
56
|
+
# Cap the TOML file size so a misconfigured `channels.toml` pointing at
|
|
57
|
+
# `/dev/urandom` or similar can't OOM the daemon at startup.
|
|
58
|
+
_MAX_TOML_BYTES = 1_000_000
|
|
59
|
+
|
|
60
|
+
# Cap the token file so `token_file = "/dev/urandom"` also can't OOM us.
|
|
61
|
+
# Real API tokens are ~64–512 bytes; 64 KB is generous.
|
|
62
|
+
_MAX_TOKEN_BYTES = 64 * 1024
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ConfigError(ValueError):
|
|
66
|
+
"""Raised for any schema violation — never caught silently."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class WeChatInstanceConfig:
|
|
71
|
+
"""One `[[channels.wechat]]` block."""
|
|
72
|
+
|
|
73
|
+
instance_id: str
|
|
74
|
+
base_url: str
|
|
75
|
+
token_env: str | None = None
|
|
76
|
+
token_file: str | None = None
|
|
77
|
+
enabled: bool = False
|
|
78
|
+
default_provider: str | None = None
|
|
79
|
+
|
|
80
|
+
def resolve_token(self, environ: dict[str, str] | None = None) -> str:
|
|
81
|
+
"""Load the token from env var or secrets file. Never logs it.
|
|
82
|
+
|
|
83
|
+
Raises ``ConfigError`` with a **redacted** message if the token can't
|
|
84
|
+
be resolved — the message references the env-var *name* or file path,
|
|
85
|
+
never the value.
|
|
86
|
+
"""
|
|
87
|
+
env = environ if environ is not None else dict(os.environ)
|
|
88
|
+
if self.token_env:
|
|
89
|
+
token = env.get(self.token_env)
|
|
90
|
+
if not token:
|
|
91
|
+
raise ConfigError(
|
|
92
|
+
f"wechat instance {self.instance_id!r}: env var "
|
|
93
|
+
f"{self.token_env!r} is unset or empty"
|
|
94
|
+
)
|
|
95
|
+
return token
|
|
96
|
+
if self.token_file:
|
|
97
|
+
path = Path(self.token_file).expanduser()
|
|
98
|
+
try:
|
|
99
|
+
# Reject anything that isn't a regular file up front so a
|
|
100
|
+
# symlink pointing at a directory / device raises a clean
|
|
101
|
+
# ConfigError instead of a raw OSError / UnicodeDecodeError.
|
|
102
|
+
if not path.is_file():
|
|
103
|
+
raise ConfigError(
|
|
104
|
+
f"wechat instance {self.instance_id!r}: token_file "
|
|
105
|
+
f"{str(path)!r} is not a regular file"
|
|
106
|
+
)
|
|
107
|
+
size = path.stat().st_size
|
|
108
|
+
if size > _MAX_TOKEN_BYTES:
|
|
109
|
+
raise ConfigError(
|
|
110
|
+
f"wechat instance {self.instance_id!r}: token_file "
|
|
111
|
+
f"{str(path)!r} exceeds {_MAX_TOKEN_BYTES}-byte limit"
|
|
112
|
+
)
|
|
113
|
+
raw = path.read_bytes()
|
|
114
|
+
except OSError as exc:
|
|
115
|
+
raise ConfigError(
|
|
116
|
+
f"wechat instance {self.instance_id!r}: cannot read "
|
|
117
|
+
f"token_file {str(path)!r}: {exc.__class__.__name__}"
|
|
118
|
+
) from None
|
|
119
|
+
try:
|
|
120
|
+
token = raw.decode("utf-8").strip()
|
|
121
|
+
except UnicodeDecodeError:
|
|
122
|
+
raise ConfigError(
|
|
123
|
+
f"wechat instance {self.instance_id!r}: token_file "
|
|
124
|
+
f"{str(path)!r} is not valid UTF-8"
|
|
125
|
+
) from None
|
|
126
|
+
if not token:
|
|
127
|
+
raise ConfigError(
|
|
128
|
+
f"wechat instance {self.instance_id!r}: token_file "
|
|
129
|
+
f"{str(path)!r} is empty"
|
|
130
|
+
)
|
|
131
|
+
return token
|
|
132
|
+
raise ConfigError(
|
|
133
|
+
f"wechat instance {self.instance_id!r}: either token_env or "
|
|
134
|
+
"token_file must be set"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class ChannelsConfig:
|
|
140
|
+
"""Root of the channels config file."""
|
|
141
|
+
|
|
142
|
+
wechat: tuple[WeChatInstanceConfig, ...] = field(default_factory=tuple)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def enabled_wechat(self) -> tuple[WeChatInstanceConfig, ...]:
|
|
146
|
+
return tuple(inst for inst in self.wechat if inst.enabled)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _require(kind: str, block: dict[str, Any], key: str) -> Any:
|
|
150
|
+
value = block.get(key)
|
|
151
|
+
if value is None or value == "":
|
|
152
|
+
raise ConfigError(f"{kind}: required field {key!r} is missing or empty")
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_wechat(block: dict[str, Any], index: int) -> WeChatInstanceConfig:
|
|
157
|
+
unknown = set(block.keys()) - _WECHAT_KEYS
|
|
158
|
+
if unknown:
|
|
159
|
+
raise ConfigError(
|
|
160
|
+
f"[[channels.wechat]] #{index}: unknown key(s) {sorted(unknown)!r}"
|
|
161
|
+
)
|
|
162
|
+
instance_id = _require(f"[[channels.wechat]] #{index}", block, "instance_id")
|
|
163
|
+
base_url = _require(f"[[channels.wechat]] #{index}", block, "base_url")
|
|
164
|
+
if not isinstance(instance_id, str):
|
|
165
|
+
raise ConfigError(f"[[channels.wechat]] #{index}: instance_id must be a string")
|
|
166
|
+
if not isinstance(base_url, str):
|
|
167
|
+
raise ConfigError(f"[[channels.wechat]] #{index}: base_url must be a string")
|
|
168
|
+
if not (base_url.startswith("http://") or base_url.startswith("https://")):
|
|
169
|
+
raise ConfigError(
|
|
170
|
+
f"[[channels.wechat]] {instance_id!r}: base_url must start with "
|
|
171
|
+
"http:// or https://"
|
|
172
|
+
)
|
|
173
|
+
# Reject `http://user:pass@host` so tokens never travel via base_url.
|
|
174
|
+
# `urlparse` folds userinfo into `netloc`; a bare `@` in the path segment
|
|
175
|
+
# after the host doesn't count because it's not in netloc.
|
|
176
|
+
if "@" in urlparse(base_url).netloc:
|
|
177
|
+
raise ConfigError(
|
|
178
|
+
f"[[channels.wechat]] {instance_id!r}: base_url must not contain "
|
|
179
|
+
"embedded credentials (user:pass@host); use token_env/token_file"
|
|
180
|
+
)
|
|
181
|
+
token_env = block.get("token_env")
|
|
182
|
+
token_file = block.get("token_file")
|
|
183
|
+
if token_env is not None and not isinstance(token_env, str):
|
|
184
|
+
raise ConfigError(f"[[channels.wechat]] {instance_id!r}: token_env must be a string")
|
|
185
|
+
if token_file is not None and not isinstance(token_file, str):
|
|
186
|
+
raise ConfigError(f"[[channels.wechat]] {instance_id!r}: token_file must be a string")
|
|
187
|
+
if token_env and token_file:
|
|
188
|
+
raise ConfigError(
|
|
189
|
+
f"[[channels.wechat]] {instance_id!r}: set token_env OR token_file, not both"
|
|
190
|
+
)
|
|
191
|
+
enabled = block.get("enabled", False)
|
|
192
|
+
# `isinstance(True, int) is True` in Python, so guard against the reverse
|
|
193
|
+
# (`enabled = 1` in TOML → int → we want a hard error, not a silent truthy).
|
|
194
|
+
if not isinstance(enabled, bool):
|
|
195
|
+
raise ConfigError(f"[[channels.wechat]] {instance_id!r}: enabled must be a bool")
|
|
196
|
+
default_provider = block.get("default_provider")
|
|
197
|
+
if default_provider is not None and not isinstance(default_provider, str):
|
|
198
|
+
raise ConfigError(
|
|
199
|
+
f"[[channels.wechat]] {instance_id!r}: default_provider must be a string"
|
|
200
|
+
)
|
|
201
|
+
return WeChatInstanceConfig(
|
|
202
|
+
instance_id=instance_id,
|
|
203
|
+
base_url=base_url.rstrip("/"),
|
|
204
|
+
token_env=token_env,
|
|
205
|
+
token_file=token_file,
|
|
206
|
+
enabled=enabled,
|
|
207
|
+
default_provider=default_provider,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def parse_channels_config(data: dict[str, Any]) -> ChannelsConfig:
|
|
212
|
+
"""Parse an already-decoded TOML mapping. Public for test use."""
|
|
213
|
+
channels = data.get("channels")
|
|
214
|
+
if channels is None:
|
|
215
|
+
return ChannelsConfig()
|
|
216
|
+
if not isinstance(channels, dict):
|
|
217
|
+
raise ConfigError("[channels]: must be a table")
|
|
218
|
+
unknown = set(channels.keys()) - _CHANNELS_KEYS
|
|
219
|
+
if unknown:
|
|
220
|
+
raise ConfigError(f"[channels]: unknown key(s) {sorted(unknown)!r}")
|
|
221
|
+
|
|
222
|
+
wechat_blocks = channels.get("wechat", [])
|
|
223
|
+
if not isinstance(wechat_blocks, list):
|
|
224
|
+
raise ConfigError("[[channels.wechat]]: must be an array of tables")
|
|
225
|
+
|
|
226
|
+
parsed: list[WeChatInstanceConfig] = []
|
|
227
|
+
seen_ids: set[str] = set()
|
|
228
|
+
for i, block in enumerate(wechat_blocks):
|
|
229
|
+
if not isinstance(block, dict):
|
|
230
|
+
raise ConfigError(f"[[channels.wechat]] #{i}: must be a table")
|
|
231
|
+
inst = _parse_wechat(block, i)
|
|
232
|
+
if inst.instance_id in seen_ids:
|
|
233
|
+
raise ConfigError(
|
|
234
|
+
f"[[channels.wechat]]: duplicate instance_id {inst.instance_id!r}"
|
|
235
|
+
)
|
|
236
|
+
seen_ids.add(inst.instance_id)
|
|
237
|
+
parsed.append(inst)
|
|
238
|
+
return ChannelsConfig(wechat=tuple(parsed))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_channels_config(path: Path) -> ChannelsConfig:
|
|
242
|
+
"""Read + parse a TOML file. Missing file → empty config (no channels).
|
|
243
|
+
|
|
244
|
+
Wraps every filesystem / decode error in ``ConfigError`` so the daemon
|
|
245
|
+
can surface a clean startup message instead of an unhandled traceback.
|
|
246
|
+
"""
|
|
247
|
+
if not path.exists():
|
|
248
|
+
return ChannelsConfig()
|
|
249
|
+
if not path.is_file():
|
|
250
|
+
raise ConfigError(f"channels config {str(path)!r} is not a regular file")
|
|
251
|
+
try:
|
|
252
|
+
size = path.stat().st_size
|
|
253
|
+
except OSError as exc:
|
|
254
|
+
raise ConfigError(
|
|
255
|
+
f"channels config {str(path)!r}: cannot stat: {exc.__class__.__name__}"
|
|
256
|
+
) from None
|
|
257
|
+
if size > _MAX_TOML_BYTES:
|
|
258
|
+
raise ConfigError(
|
|
259
|
+
f"channels config {str(path)!r} exceeds {_MAX_TOML_BYTES}-byte limit "
|
|
260
|
+
f"({size} bytes)"
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
with path.open("rb") as f:
|
|
264
|
+
data = _toml.load(f)
|
|
265
|
+
except _toml.TOMLDecodeError as exc:
|
|
266
|
+
raise ConfigError(
|
|
267
|
+
f"channels config {str(path)!r} is not valid TOML: {exc}"
|
|
268
|
+
) from None
|
|
269
|
+
except OSError as exc:
|
|
270
|
+
raise ConfigError(
|
|
271
|
+
f"channels config {str(path)!r}: cannot read: {exc.__class__.__name__}"
|
|
272
|
+
) from None
|
|
273
|
+
return parse_channels_config(data)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
__all__ = [
|
|
277
|
+
"ChannelsConfig",
|
|
278
|
+
"ConfigError",
|
|
279
|
+
"WeChatInstanceConfig",
|
|
280
|
+
"load_channels_config",
|
|
281
|
+
"parse_channels_config",
|
|
282
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""WeChat channel adapter — personal-WeChat gateway integration.
|
|
2
|
+
|
|
3
|
+
The WeChat adapter is CodingBridge's first :class:`~coding_bridge.channels.ChannelAdapter`
|
|
4
|
+
concrete implementation. It talks to a personal-WeChat gateway (FastAPI, hosted
|
|
5
|
+
on a CVM) with two edges:
|
|
6
|
+
|
|
7
|
+
* ``wss://<host>/ws?token=<TOKEN>`` — the gateway broadcasts every WeChat message
|
|
8
|
+
event as JSON ``{"event": "message.new", "data": {...}}``. The adapter
|
|
9
|
+
filters ``direction=inbound`` structurally (echoes / outbound get skipped)
|
|
10
|
+
and hands anything left to the dispatcher — policy (trigger prefix,
|
|
11
|
+
allowlist, rate limit) lives one layer up in P7.
|
|
12
|
+
* ``POST /api/messages/send`` with ``Authorization: Bearer <TOKEN>`` — returns
|
|
13
|
+
``202 Accepted`` because the gateway queues a background UI task. P2 fires and
|
|
14
|
+
forgets; P7 will poll ``/api/messages/tasks/{id}`` for delivery
|
|
15
|
+
confirmation.
|
|
16
|
+
|
|
17
|
+
The adapter is import-safe without extras — it delegates to :mod:`websockets`
|
|
18
|
+
and :mod:`httpx`, both already required by CodingBridge core. The
|
|
19
|
+
``[wechat]`` extras marker exists only so users can express intent
|
|
20
|
+
(``pip install coding-bridge[wechat]``); no additional wheels are pulled.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .adapter import WeChatAdapter
|
|
24
|
+
from .client import WeChatClient
|
|
25
|
+
|
|
26
|
+
__all__ = ["WeChatAdapter", "WeChatClient"]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""``WeChatAdapter`` — subscribes to the WeChat gateway's WS feed and dispatches messages.
|
|
2
|
+
|
|
3
|
+
The adapter is a *pure wire*: it opens the WSS connection, decodes
|
|
4
|
+
``message.new`` events, filters out anything that isn't a real inbound WeChat
|
|
5
|
+
message (echoes, outbound, non-text types), and hands the rest to the
|
|
6
|
+
dispatcher-installed handler. Policy — trigger prefix, sender allowlist, rate
|
|
7
|
+
limit, dedup — lives in the abuse-control layer landing with P7.
|
|
8
|
+
|
|
9
|
+
Design notes:
|
|
10
|
+
|
|
11
|
+
* Reconnect loop uses bounded exponential backoff (0.5s → 30s) so a gateway
|
|
12
|
+
restart doesn't take the adapter down.
|
|
13
|
+
* The token appears only in the WS query string and the REST client's
|
|
14
|
+
``Authorization`` header. Logs redact both — every URL echoed to the logs
|
|
15
|
+
runs through :func:`_redact_url` first, and the REST client never echoes
|
|
16
|
+
the token in error messages.
|
|
17
|
+
* ``aclose()`` is idempotent and reentrant — closing an already-closed
|
|
18
|
+
adapter is a no-op and never raises.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import contextlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
from typing import Any
|
|
29
|
+
from urllib.parse import urlparse, urlunparse
|
|
30
|
+
|
|
31
|
+
import websockets
|
|
32
|
+
|
|
33
|
+
from ..base import ChannelTarget, IncomingMessage, MessageHandler, SendResult
|
|
34
|
+
from .client import WeChatClient
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("coding-bridge.channels.wechat")
|
|
37
|
+
|
|
38
|
+
_INITIAL_BACKOFF_S = 0.5
|
|
39
|
+
_MAX_BACKOFF_S = 30.0
|
|
40
|
+
_HTTP_TO_WS = {"http": "ws", "https": "wss"}
|
|
41
|
+
# Any query-string token or password segment is redacted before logging.
|
|
42
|
+
_TOKEN_QS_RE = re.compile(r"([?&](?:token|api_token|password)=)([^&]+)", re.IGNORECASE)
|
|
43
|
+
# ``user:pass@`` in the netloc is also stripped — a misconfigured base_url with
|
|
44
|
+
# embedded credentials would otherwise survive query-string redaction.
|
|
45
|
+
_USERINFO_RE = re.compile(r"(?<=://)([^/@]+)@")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _redact_url(url: str) -> str:
|
|
49
|
+
"""Strip token / password from URL query string + userinfo for safe logging."""
|
|
50
|
+
return _USERINFO_RE.sub("<redacted>@", _TOKEN_QS_RE.sub(r"\1<redacted>", url))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_ws_url(base_url: str, token: str) -> str:
|
|
54
|
+
parsed = urlparse(base_url.rstrip("/"))
|
|
55
|
+
scheme = _HTTP_TO_WS.get(parsed.scheme, parsed.scheme or "ws")
|
|
56
|
+
path = (parsed.path or "").rstrip("/") + "/ws"
|
|
57
|
+
return urlunparse((scheme, parsed.netloc, path, "", f"token={token}", ""))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_incoming(payload: dict[str, Any]) -> IncomingMessage | None:
|
|
61
|
+
"""Best-effort parser for the gateway's ``message.new`` event.
|
|
62
|
+
|
|
63
|
+
Returns ``None`` for anything the dispatcher should never see (outbound
|
|
64
|
+
echoes, unknown event types, missing required fields). Downstream policy
|
|
65
|
+
(allowlist / trigger prefix) runs against the returned message.
|
|
66
|
+
"""
|
|
67
|
+
event = payload.get("event")
|
|
68
|
+
data = payload.get("data")
|
|
69
|
+
if event != "message.new" or not isinstance(data, dict):
|
|
70
|
+
return None
|
|
71
|
+
direction = data.get("direction")
|
|
72
|
+
if direction != "inbound":
|
|
73
|
+
return None
|
|
74
|
+
text = data.get("text")
|
|
75
|
+
if not isinstance(text, str) or not text:
|
|
76
|
+
return None
|
|
77
|
+
target = data.get("target")
|
|
78
|
+
if not isinstance(target, str) or not target:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
return IncomingMessage(
|
|
82
|
+
sender_id=str(data.get("sender_id") or target),
|
|
83
|
+
sender_name=data.get("sender_name") if isinstance(data.get("sender_name"), str) else None,
|
|
84
|
+
target=ChannelTarget(
|
|
85
|
+
conversation_id=target,
|
|
86
|
+
conversation_type=str(data.get("conversation_type") or "private"),
|
|
87
|
+
reply_to_id=data.get("msg_id") if isinstance(data.get("msg_id"), str) else None,
|
|
88
|
+
),
|
|
89
|
+
text=text,
|
|
90
|
+
msg_type=str(data.get("msg_type") or "text"),
|
|
91
|
+
direction=direction,
|
|
92
|
+
upstream_id=data.get("msg_id") if isinstance(data.get("msg_id"), str) else None,
|
|
93
|
+
received_at_ms=int(data["timestamp"]) if isinstance(data.get("timestamp"), int) else None,
|
|
94
|
+
raw=dict(data),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class WeChatAdapter:
|
|
99
|
+
"""One WeChat gateway endpoint = one WeChat instance = one adapter.
|
|
100
|
+
|
|
101
|
+
Runs a WSS receive loop; delivery is done via the injected
|
|
102
|
+
:class:`WeChatClient`. Only intended for private / group WeChat text
|
|
103
|
+
messages — image / file / audio events are silently dropped in P2 (P8+
|
|
104
|
+
can add multi-modal handling once the dispatcher supports attachments).
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
name = "wechat"
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
instance_id: str,
|
|
113
|
+
base_url: str,
|
|
114
|
+
token: str,
|
|
115
|
+
client: WeChatClient | None = None,
|
|
116
|
+
ws_connect: Any | None = None,
|
|
117
|
+
stop_event: asyncio.Event | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
if not instance_id:
|
|
120
|
+
raise ValueError("instance_id must be a non-empty string")
|
|
121
|
+
if not base_url:
|
|
122
|
+
raise ValueError("base_url must be a non-empty string")
|
|
123
|
+
if not token:
|
|
124
|
+
raise ValueError("token must be a non-empty string")
|
|
125
|
+
self.instance_id = instance_id
|
|
126
|
+
self._base_url = base_url.rstrip("/")
|
|
127
|
+
self._token = token
|
|
128
|
+
self._client = client or WeChatClient(base_url, token)
|
|
129
|
+
# ``ws_connect`` is injected only by tests; production uses
|
|
130
|
+
# :func:`websockets.connect` directly.
|
|
131
|
+
self._ws_connect = ws_connect or websockets.connect
|
|
132
|
+
self._stop = stop_event or asyncio.Event()
|
|
133
|
+
self._handler: MessageHandler | None = None
|
|
134
|
+
self._owns_client = client is None
|
|
135
|
+
# Held for the lifetime of one WS session so ``aclose()`` can force
|
|
136
|
+
# the receive loop to unblock without waiting for a gateway heartbeat.
|
|
137
|
+
self._active_ws: Any | None = None
|
|
138
|
+
|
|
139
|
+
def set_handler(self, handler: MessageHandler) -> None:
|
|
140
|
+
self._handler = handler
|
|
141
|
+
|
|
142
|
+
async def run(self) -> None:
|
|
143
|
+
if self._handler is None:
|
|
144
|
+
raise RuntimeError("WeChatAdapter.run() called before set_handler()")
|
|
145
|
+
ws_url = _build_ws_url(self._base_url, self._token)
|
|
146
|
+
backoff = _INITIAL_BACKOFF_S
|
|
147
|
+
while not self._stop.is_set():
|
|
148
|
+
try:
|
|
149
|
+
logger.info(
|
|
150
|
+
"wechat: connecting instance=%s url=%s",
|
|
151
|
+
self.instance_id,
|
|
152
|
+
_redact_url(ws_url),
|
|
153
|
+
)
|
|
154
|
+
async with self._ws_connect(ws_url) as ws:
|
|
155
|
+
logger.info("wechat: connected instance=%s", self.instance_id)
|
|
156
|
+
backoff = _INITIAL_BACKOFF_S
|
|
157
|
+
self._active_ws = ws
|
|
158
|
+
try:
|
|
159
|
+
await self._consume(ws)
|
|
160
|
+
finally:
|
|
161
|
+
self._active_ws = None
|
|
162
|
+
except asyncio.CancelledError:
|
|
163
|
+
raise
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
if self._stop.is_set():
|
|
166
|
+
return
|
|
167
|
+
logger.warning(
|
|
168
|
+
"wechat: WS loop error instance=%s err=%s.%s; reconnect in %.1fs",
|
|
169
|
+
self.instance_id,
|
|
170
|
+
exc.__class__.__module__,
|
|
171
|
+
exc.__class__.__name__,
|
|
172
|
+
backoff,
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
await asyncio.wait_for(self._stop.wait(), timeout=backoff)
|
|
176
|
+
return # stop signaled during backoff
|
|
177
|
+
except asyncio.TimeoutError:
|
|
178
|
+
pass
|
|
179
|
+
backoff = min(backoff * 2, _MAX_BACKOFF_S)
|
|
180
|
+
|
|
181
|
+
async def _consume(self, ws: Any) -> None:
|
|
182
|
+
async for raw in ws:
|
|
183
|
+
if self._stop.is_set():
|
|
184
|
+
return
|
|
185
|
+
try:
|
|
186
|
+
payload = json.loads(raw) if isinstance(raw, (str, bytes, bytearray)) else raw
|
|
187
|
+
except (ValueError, TypeError):
|
|
188
|
+
logger.debug("wechat: skipped non-JSON frame instance=%s", self.instance_id)
|
|
189
|
+
continue
|
|
190
|
+
if not isinstance(payload, dict):
|
|
191
|
+
continue
|
|
192
|
+
msg = _parse_incoming(payload)
|
|
193
|
+
if msg is None:
|
|
194
|
+
continue
|
|
195
|
+
assert self._handler is not None # invariant checked in run()
|
|
196
|
+
try:
|
|
197
|
+
await self._handler(msg, self)
|
|
198
|
+
except Exception:
|
|
199
|
+
# A single handler failure must not kill the receive loop —
|
|
200
|
+
# the dispatcher already logs its own errors, and abusive
|
|
201
|
+
# senders shouldn't be able to DoS the adapter.
|
|
202
|
+
logger.exception("wechat: handler error instance=%s", self.instance_id)
|
|
203
|
+
|
|
204
|
+
async def send(
|
|
205
|
+
self, target: ChannelTarget, text: str, *, reply_to: str | None = None
|
|
206
|
+
) -> SendResult:
|
|
207
|
+
return await self._client.send_message(target, text, reply_to=reply_to)
|
|
208
|
+
|
|
209
|
+
async def aclose(self) -> None:
|
|
210
|
+
"""Idempotent shutdown; safe to call from multiple coroutines."""
|
|
211
|
+
self._stop.set()
|
|
212
|
+
ws = self._active_ws
|
|
213
|
+
if ws is not None:
|
|
214
|
+
close = getattr(ws, "close", None)
|
|
215
|
+
if callable(close):
|
|
216
|
+
with contextlib.suppress(Exception):
|
|
217
|
+
result = close()
|
|
218
|
+
if asyncio.iscoroutine(result):
|
|
219
|
+
await result
|
|
220
|
+
if self._owns_client:
|
|
221
|
+
with contextlib.suppress(Exception):
|
|
222
|
+
await self._client.aclose()
|