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.
Files changed (67) hide show
  1. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/PKG-INFO +3 -1
  2. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/channels/__init__.py +12 -0
  3. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/channels/base.py +6 -6
  4. coding_bridge-2026.7.3.2/coding_bridge/channels/config.py +282 -0
  5. coding_bridge-2026.7.3.2/coding_bridge/channels/wechat/__init__.py +26 -0
  6. coding_bridge-2026.7.3.2/coding_bridge/channels/wechat/adapter.py +222 -0
  7. coding_bridge-2026.7.3.2/coding_bridge/channels/wechat/client.py +106 -0
  8. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/config.py +5 -0
  9. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/pyproject.toml +6 -1
  10. coding_bridge-2026.7.3.2/tests/test_channels_config.py +384 -0
  11. coding_bridge-2026.7.3.2/tests/test_channels_wechat.py +502 -0
  12. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/.env.example +0 -0
  13. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/.github/workflows/ci.yaml +0 -0
  14. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/.github/workflows/publish.yml +0 -0
  15. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/.gitignore +0 -0
  16. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/LICENSE +0 -0
  17. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/NOTICE +0 -0
  18. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/README.md +0 -0
  19. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/__init__.py +0 -0
  20. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/__main__.py +0 -0
  21. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/attachments.py +0 -0
  22. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/capabilities.py +0 -0
  23. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/channels/dispatcher.py +0 -0
  24. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/cli.py +0 -0
  25. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/connection.py +0 -0
  26. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/fs.py +0 -0
  27. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/history.py +0 -0
  28. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/images.py +0 -0
  29. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/locking.py +0 -0
  30. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/logs.py +0 -0
  31. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/pairing.py +0 -0
  32. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/permissions.py +0 -0
  33. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/protocol.py +0 -0
  34. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/providers/__init__.py +0 -0
  35. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/providers/base.py +0 -0
  36. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/providers/claude.py +0 -0
  37. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/providers/codex.py +0 -0
  38. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/providers/copilot.py +0 -0
  39. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/session.py +0 -0
  40. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/session_meta.py +0 -0
  41. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/coding_bridge/store.py +0 -0
  42. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/docs/design/reliable-event-delivery.md +0 -0
  43. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/conftest.py +0 -0
  44. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_ask_user_question.py +0 -0
  45. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_attachments.py +0 -0
  46. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_capabilities.py +0 -0
  47. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_channels_base.py +0 -0
  48. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_channels_dispatcher.py +0 -0
  49. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_codex.py +0 -0
  50. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_config.py +0 -0
  51. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_connection.py +0 -0
  52. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_copilot.py +0 -0
  53. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_edit_fork.py +0 -0
  54. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_fs.py +0 -0
  55. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_history.py +0 -0
  56. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_images.py +0 -0
  57. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_locking.py +0 -0
  58. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_outbox.py +0 -0
  59. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_pairing.py +0 -0
  60. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_permissions.py +0 -0
  61. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_protocol.py +0 -0
  62. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_resume_guard.py +0 -0
  63. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_session.py +0 -0
  64. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_session_meta.py +0 -0
  65. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_slash_commands.py +0 -0
  66. {coding_bridge-2026.7.3.0 → coding_bridge-2026.7.3.2}/tests/test_store.py +0 -0
  67. {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.0
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 via
4
- Wisdom, later Telegram / Discord …) and the dispatcher. It never spawns provider
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 Wisdom endpoint, per Bot token, …) so multi-instance
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
- Wisdom's ``msg_id``, Telegram's ``reply_to_message_id``); adapters that lack
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 Wisdom's schema)
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()