quadwork 1.19.2 → 2.0.0

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 (117) hide show
  1. package/README.md +19 -35
  2. package/bin/quadwork.js +48 -1118
  3. package/out/404.html +1 -1
  4. package/out/__next.__PAGE__.txt +3 -3
  5. package/out/__next._full.txt +14 -14
  6. package/out/__next._head.txt +4 -4
  7. package/out/__next._index.txt +8 -8
  8. package/out/__next._tree.txt +2 -2
  9. package/out/_next/static/chunks/{030cjkhts487t.js → 079wdniva~de1.js} +1 -1
  10. package/out/_next/static/chunks/{0n~dq4kpx9xxx.js → 07lhk_q6pmm3r.js} +1 -1
  11. package/out/_next/static/chunks/0_79hkefw1mo2.js +1 -0
  12. package/out/_next/static/chunks/{153f.fj8jlvle.js → 0_lyyn..t63bc.js} +1 -1
  13. package/out/_next/static/chunks/0oxv9vrvc17to.js +2 -0
  14. package/out/_next/static/chunks/0py7102i226n5.js +1 -0
  15. package/out/_next/static/chunks/{13fv-yi7.v52g.js → 0q4bm04c1jl_3.js} +1 -1
  16. package/out/_next/static/chunks/{0_idxioyl0p7h.js → 0sjhy6oe3mbon.js} +1 -1
  17. package/out/_next/static/chunks/13xk0vgfbrcld.css +2 -0
  18. package/out/_next/static/chunks/14k3bfe537f9_.js +25 -0
  19. package/out/_next/static/chunks/{turbopack-0qm-e3ifrz~2u.js → turbopack-0y2u-q0l2m67w.js} +1 -1
  20. package/out/_not-found/__next._full.txt +13 -13
  21. package/out/_not-found/__next._head.txt +4 -4
  22. package/out/_not-found/__next._index.txt +8 -8
  23. package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
  24. package/out/_not-found/__next._not-found.txt +3 -3
  25. package/out/_not-found/__next._tree.txt +2 -2
  26. package/out/_not-found.html +1 -1
  27. package/out/_not-found.txt +13 -13
  28. package/out/app-shell/__next._full.txt +13 -13
  29. package/out/app-shell/__next._head.txt +4 -4
  30. package/out/app-shell/__next._index.txt +8 -8
  31. package/out/app-shell/__next._tree.txt +2 -2
  32. package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
  33. package/out/app-shell/__next.app-shell.txt +3 -3
  34. package/out/app-shell.html +1 -1
  35. package/out/app-shell.txt +13 -13
  36. package/out/index.html +1 -1
  37. package/out/index.txt +14 -14
  38. package/out/project/_/__next._full.txt +14 -14
  39. package/out/project/_/__next._head.txt +4 -4
  40. package/out/project/_/__next._index.txt +8 -8
  41. package/out/project/_/__next._tree.txt +2 -2
  42. package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
  43. package/out/project/_/__next.project.$d$id.txt +3 -3
  44. package/out/project/_/__next.project.txt +3 -3
  45. package/out/project/_/queue/__next._full.txt +14 -14
  46. package/out/project/_/queue/__next._head.txt +4 -4
  47. package/out/project/_/queue/__next._index.txt +8 -8
  48. package/out/project/_/queue/__next._tree.txt +2 -2
  49. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
  50. package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
  51. package/out/project/_/queue/__next.project.$d$id.txt +3 -3
  52. package/out/project/_/queue/__next.project.txt +3 -3
  53. package/out/project/_/queue.html +1 -1
  54. package/out/project/_/queue.txt +14 -14
  55. package/out/project/_.html +1 -1
  56. package/out/project/_.txt +14 -14
  57. package/out/settings/__next._full.txt +14 -14
  58. package/out/settings/__next._head.txt +4 -4
  59. package/out/settings/__next._index.txt +8 -8
  60. package/out/settings/__next._tree.txt +2 -2
  61. package/out/settings/__next.settings.__PAGE__.txt +3 -3
  62. package/out/settings/__next.settings.txt +3 -3
  63. package/out/settings.html +1 -1
  64. package/out/settings.txt +14 -14
  65. package/out/setup/__next._full.txt +14 -14
  66. package/out/setup/__next._head.txt +4 -4
  67. package/out/setup/__next._index.txt +8 -8
  68. package/out/setup/__next._tree.txt +2 -2
  69. package/out/setup/__next.setup.__PAGE__.txt +3 -3
  70. package/out/setup/__next.setup.txt +3 -3
  71. package/out/setup.html +1 -1
  72. package/out/setup.txt +14 -14
  73. package/package.json +4 -2
  74. package/server/ac-restore.js +128 -0
  75. package/server/bridges/discord.js +183 -0
  76. package/server/bridges/telegram.js +210 -0
  77. package/server/config.js +4 -60
  78. package/server/file-chat.js +318 -0
  79. package/server/index.js +173 -1286
  80. package/server/install-agentchattr.js +3 -284
  81. package/server/mcp-chat-shim.js +171 -0
  82. package/server/migrate-ac.js +158 -0
  83. package/server/pty-dispatcher.js +188 -0
  84. package/server/routes.js +149 -1397
  85. package/templates/CLAUDE.md +2 -2
  86. package/templates/OVERNIGHT-QUEUE.md +1 -1
  87. package/templates/seeds/butler.CLAUDE.md +30 -62
  88. package/templates/seeds/dev.AGENTS.md +10 -1
  89. package/templates/seeds/head.AGENTS.md +3 -3
  90. package/templates/seeds/re1.AGENTS.md +3 -3
  91. package/templates/seeds/re2.AGENTS.md +3 -3
  92. package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
  93. package/bridges/discord/discord_bridge.py +0 -666
  94. package/bridges/discord/requirements.txt +0 -2
  95. package/out/_next/static/chunks/0_bb~2.5h2ntm.css +0 -2
  96. package/out/_next/static/chunks/0makcdqkwobp6.js +0 -25
  97. package/out/_next/static/chunks/0uz5svjlo9dwl.js +0 -1
  98. package/out/_next/static/chunks/0zahstmgdrpy5.js +0 -1
  99. package/out/_next/static/chunks/0zfotsowwll1x.js +0 -2
  100. package/server/__tests__/bridge-auto-stop-guard.test.js +0 -134
  101. package/server/__tests__/rate-limit-handling.test.js +0 -168
  102. package/server/__tests__/scrub-secrets.test.js +0 -235
  103. package/server/__tests__/v1110-security-qa.test.js +0 -312
  104. package/server/agentchattr-registry.js +0 -188
  105. package/server/install-agentchattr.patchCrashTimeout.test.js +0 -71
  106. package/server/queue-watcher.js +0 -171
  107. package/server/queue-watcher.test.js +0 -64
  108. package/server/routes.batchProgress.test.js +0 -94
  109. package/server/routes.chatWsSend.test.js +0 -161
  110. package/server/routes.discordBridge.test.js +0 -80
  111. package/server/routes.parseActiveBatch.test.js +0 -88
  112. package/server/routes.telegramBridge.test.js +0 -241
  113. package/templates/config.toml +0 -72
  114. package/templates/wrapper.py +0 -70
  115. /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_buildManifest.js +0 -0
  116. /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_clientMiddlewareManifest.js +0 -0
  117. /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_ssgManifest.js +0 -0
@@ -1,666 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Discord ↔ AgentChattr bridge.
4
-
5
- Bidirectional relay: messages from a Discord channel appear in
6
- AgentChattr, and agent messages from AC appear in Discord.
7
-
8
- Mirrors the Telegram bridge (agentchattr-telegram/telegram_bridge.py)
9
- as closely as possible. Bundled inside the quadwork npm package at
10
- bridges/discord/ instead of a separate repo.
11
-
12
- Config: read from TOML [discord] section, env var overrides win.
13
- """
14
-
15
- import argparse
16
- import asyncio
17
- import atexit
18
- import json
19
- import logging
20
- import os
21
- import re
22
- import signal
23
- import sys
24
- import threading
25
- import time
26
- from pathlib import Path
27
-
28
- try:
29
- import tomllib # Python 3.11+
30
- except ModuleNotFoundError:
31
- try:
32
- import tomli as tomllib # type: ignore[no-redef]
33
- except ModuleNotFoundError:
34
- tomllib = None # type: ignore[assignment]
35
-
36
- import discord
37
- import requests
38
-
39
- log = logging.getLogger("dc-bridge")
40
-
41
- # ---------------------------------------------------------------------------
42
- # Config
43
- # ---------------------------------------------------------------------------
44
-
45
- DEFAULT_CONFIG = {
46
- "bot_token": "",
47
- "channel_id": "",
48
- "agentchattr_url": "http://127.0.0.1:8300",
49
- "poll_interval": 2,
50
- "bridge_sender": "dc",
51
- "cursor_file": "",
52
- "project_id": "", # #525: used to read bridge_filter_agents_only from config
53
- }
54
-
55
- ENV_MAP = {
56
- "DISCORD_BOT_TOKEN": "bot_token",
57
- "DISCORD_CHANNEL_ID": "channel_id",
58
- "AGENTCHATTR_URL": "agentchattr_url",
59
- "CURSOR_FILE": "cursor_file",
60
- }
61
-
62
-
63
- def load_config(toml_path=None):
64
- """Load config: defaults → TOML [discord] → env vars."""
65
- cfg = dict(DEFAULT_CONFIG)
66
-
67
- if toml_path and os.path.isfile(toml_path):
68
- if tomllib is None:
69
- log.warning("tomli not installed and Python < 3.11; skipping TOML config")
70
- else:
71
- with open(toml_path, "rb") as f:
72
- data = tomllib.load(f)
73
- section = data.get("discord", {})
74
- for key in cfg:
75
- if key in section:
76
- cfg[key] = section[key]
77
- # Resolve cursor_file relative to TOML directory
78
- if cfg["cursor_file"] and not os.path.isabs(cfg["cursor_file"]):
79
- cfg["cursor_file"] = os.path.join(
80
- os.path.dirname(os.path.abspath(toml_path)),
81
- cfg["cursor_file"],
82
- )
83
-
84
- # Env vars always override
85
- for env_key, cfg_key in ENV_MAP.items():
86
- val = os.environ.get(env_key)
87
- if val:
88
- cfg[cfg_key] = val
89
-
90
- # bot_token may use "env:VAR" indirection (same as TG bridge)
91
- if cfg["bot_token"].startswith("env:"):
92
- env_name = cfg["bot_token"][4:]
93
- cfg["bot_token"] = os.environ.get(env_name, "")
94
-
95
- # channel_id must be an integer for discord.py comparisons
96
- if cfg["channel_id"]:
97
- cfg["channel_id"] = int(cfg["channel_id"])
98
-
99
- return cfg
100
-
101
-
102
- def validate_config(cfg):
103
- """Raise on missing required fields."""
104
- if not cfg["bot_token"]:
105
- raise SystemExit("bot_token is required (TOML [discord] or DISCORD_BOT_TOKEN env)")
106
- if not cfg["channel_id"]:
107
- raise SystemExit("channel_id is required (TOML [discord] or DISCORD_CHANNEL_ID env)")
108
-
109
-
110
- # ---------------------------------------------------------------------------
111
- # Cursor persistence
112
- # ---------------------------------------------------------------------------
113
-
114
- _cursor = {"last_seen_id": 0}
115
-
116
-
117
- def load_cursor(path):
118
- """Load cursor from JSON file. Non-fatal on error."""
119
- global _cursor
120
- if not path or not os.path.isfile(path):
121
- return
122
- try:
123
- with open(path) as f:
124
- data = json.load(f)
125
- if isinstance(data, dict) and "last_seen_id" in data:
126
- _cursor["last_seen_id"] = int(data["last_seen_id"])
127
- log.info("Loaded cursor: last_seen_id=%d", _cursor["last_seen_id"])
128
- except Exception as exc:
129
- log.warning("Failed to load cursor from %s: %s", path, exc)
130
-
131
-
132
- def save_cursor(path):
133
- """Save cursor to JSON file. Non-fatal on error."""
134
- if not path:
135
- return
136
- try:
137
- os.makedirs(os.path.dirname(path), exist_ok=True)
138
- with open(path, "w") as f:
139
- json.dump(_cursor, f)
140
- except Exception as exc:
141
- log.warning("Failed to save cursor to %s: %s", path, exc)
142
-
143
-
144
- def _seed_cursor_to_latest(url, cursor_file):
145
- """#500: Skip to latest AC message so reconnect doesn't replay old messages."""
146
- try:
147
- headers = {}
148
- if ac["token"]:
149
- headers["Authorization"] = f"Bearer {ac['token']}"
150
- resp = requests.get(f"{url}/api/messages", params={"limit": 1}, headers=headers, timeout=10)
151
- if resp.ok:
152
- msgs = resp.json()
153
- if isinstance(msgs, list) and msgs:
154
- latest_id = max(m.get("id", 0) for m in msgs)
155
- if latest_id > _cursor["last_seen_id"]:
156
- _cursor["last_seen_id"] = latest_id
157
- save_cursor(cursor_file)
158
- log.info("Seeded cursor to latest: last_seen_id=%d", latest_id)
159
- except Exception as exc:
160
- log.warning("Failed to seed cursor to latest: %s", exc)
161
-
162
-
163
- # ---------------------------------------------------------------------------
164
- # AgentChattr registration + heartbeat
165
- # ---------------------------------------------------------------------------
166
-
167
- # Mutable dict so heartbeat thread sees re-registration updates.
168
- # bridge_sender is set from cfg during main() so all callers can read it.
169
- ac = {"token": "", "name": "", "bridge_sender": "dc", "known_names": set()}
170
-
171
-
172
- def ac_register(url, base=None, label="Discord Bridge"):
173
- """Register with AgentChattr. Returns {name, token} or raises."""
174
- if base is None:
175
- base = ac["bridge_sender"]
176
- resp = requests.post(
177
- f"{url}/api/register",
178
- json={"base": base, "label": label},
179
- timeout=10,
180
- )
181
- resp.raise_for_status()
182
- data = resp.json()
183
- ac["name"] = data["name"]
184
- ac["token"] = data["token"]
185
- ac["known_names"].add(data["name"])
186
- log.info("Registered with AC as %s (known: %s)", ac["name"], ac["known_names"])
187
- return data
188
-
189
-
190
- def ac_deregister(url):
191
- """Best-effort deregister from AC."""
192
- if not ac["name"]:
193
- return
194
- try:
195
- requests.post(
196
- f"{url}/api/deregister/{ac['name']}",
197
- headers={"Authorization": f"Bearer {ac['token']}"},
198
- timeout=5,
199
- )
200
- log.info("Deregistered %s from AC", ac["name"])
201
- except Exception:
202
- pass
203
-
204
-
205
- def _heartbeat_loop(url, cursor_file):
206
- """Daemon thread: POST /api/heartbeat/{name} every 5s."""
207
- while True:
208
- name = ac["name"]
209
- token = ac["token"]
210
- if name:
211
- try:
212
- resp = requests.post(
213
- f"{url}/api/heartbeat/{name}",
214
- headers={"Authorization": f"Bearer {token}"} if token else {},
215
- timeout=5,
216
- )
217
- if resp.status_code == 409:
218
- # AC restarted — re-register
219
- log.warning("Heartbeat 409 — AC restarted, re-registering")
220
- try:
221
- ac_register(url)
222
- _seed_cursor_to_latest(url, cursor_file)
223
- except Exception as exc:
224
- log.error("Re-register failed: %s", exc)
225
- except Exception:
226
- pass
227
- time.sleep(5)
228
-
229
-
230
- def start_heartbeat(url, cursor_file=""):
231
- """Start the heartbeat daemon thread."""
232
- t = threading.Thread(target=_heartbeat_loop, args=(url, cursor_file), daemon=True)
233
- t.start()
234
- return t
235
-
236
-
237
- # ---------------------------------------------------------------------------
238
- # #525: read bridge_filter_agents_only from ~/.quadwork/config.json
239
- # ---------------------------------------------------------------------------
240
-
241
- _CONFIG_JSON = os.path.join(os.path.expanduser("~"), ".quadwork", "config.json")
242
- _agents_only_cache: dict[str, tuple[float, bool]] = {} # project_id → (ts, value)
243
- _AGENTS_ONLY_TTL = 10 # seconds — recheck config every 10s
244
-
245
-
246
- def _is_agents_only(project_id: str) -> bool:
247
- """Check whether the operator has enabled 'Agents only' for this project."""
248
- now = time.time()
249
- cached = _agents_only_cache.get(project_id)
250
- if cached and now - cached[0] < _AGENTS_ONLY_TTL:
251
- return cached[1]
252
- val = False
253
- try:
254
- with open(_CONFIG_JSON, "r") as f:
255
- cfg = json.load(f)
256
- for p in cfg.get("projects", []):
257
- if p.get("id") == project_id:
258
- val = bool(p.get("bridge_filter_agents_only", False))
259
- break
260
- except Exception:
261
- pass
262
- _agents_only_cache[project_id] = (now, val)
263
- return val
264
-
265
-
266
- # ---------------------------------------------------------------------------
267
- # #501: message filter — suppress AC housekeeping noise from bridge output
268
- # ---------------------------------------------------------------------------
269
-
270
- _NOISE_PATTERNS = [
271
- re.compile(r"^.+ is online$"),
272
- re.compile(r"disconnected \(timeout\)"),
273
- re.compile(r"^.+ disconnected$"),
274
- re.compile(r"auto-recovered"),
275
- re.compile(r"Resuming agent conversation"),
276
- ]
277
-
278
- # Dedup guard: (sender, text) → timestamp of last forward
279
- _last_forwarded: dict[tuple[str, str], float] = {}
280
- _DEDUP_WINDOW = 60 # seconds
281
-
282
-
283
- def _should_forward(msg: dict, agents_only: bool = False) -> bool:
284
- """Return True if this AC message should be forwarded. Pure check, no side effects.
285
-
286
- #525: ALL content filtering is controlled by the dashboard "Agents only"
287
- toggle. When OFF, bridges forward everything (only dedup guard remains).
288
- When ON, bridges apply the same filters as the dashboard's isSystemMessage.
289
- """
290
- sender = msg.get("sender", "")
291
- text = msg.get("text", "")
292
- msg_type = msg.get("type", "chat")
293
-
294
- # #525: content filtering only when agents_only is enabled
295
- if agents_only:
296
- # Skip join/leave system messages (online, disconnected, timeout)
297
- if msg_type in ("join", "leave"):
298
- return False
299
-
300
- # Skip system sender entirely
301
- if sender == "system":
302
- return False
303
-
304
- # Pattern-based filter — matches dashboard's isSystemMessage patterns
305
- for pat in _NOISE_PATTERNS:
306
- if pat.search(text):
307
- return False
308
-
309
- # Loop guard messages
310
- if "Loop guard:" in text:
311
- return False
312
-
313
- # Dedup: suppress identical (sender, text) within window (always on)
314
- key = (sender, text)
315
- now = time.time()
316
- last = _last_forwarded.get(key)
317
- if last is not None and now - last < _DEDUP_WINDOW:
318
- return False
319
-
320
- return True
321
-
322
-
323
- def _mark_forwarded(msg: dict):
324
- """Record that a message was successfully forwarded. Call after delivery."""
325
- key = (msg.get("sender", ""), msg.get("text", ""))
326
- _last_forwarded[key] = time.time()
327
-
328
- # Prune stale dedup entries periodically
329
- if len(_last_forwarded) > 500:
330
- cutoff = time.time() - _DEDUP_WINDOW
331
- stale = [k for k, t in _last_forwarded.items() if t < cutoff]
332
- for k in stale:
333
- del _last_forwarded[k]
334
-
335
-
336
- # ---------------------------------------------------------------------------
337
- # AC → Discord polling
338
- # ---------------------------------------------------------------------------
339
-
340
- async def poll_ac_to_discord(cfg, channel):
341
- """Poll AC for new messages and forward to Discord channel."""
342
- url = cfg["agentchattr_url"]
343
- bridge_sender = cfg["bridge_sender"]
344
- interval = cfg["poll_interval"]
345
-
346
- # #500: track connection failures so we can seed cursor on recovery
347
- poll_was_failing = False
348
-
349
- # #458: dedup guard — track recently forwarded message IDs so a
350
- # stale cursor, drain-loop hiccup, or restart replay can't send
351
- # the same AC message to Discord twice within a session.
352
- forwarded_ids: set[int] = set()
353
- # Cap the set size to avoid unbounded growth in long-running sessions.
354
- MAX_FORWARDED = 2000
355
-
356
- while True:
357
- try:
358
- # Drain all available messages before sleeping. When AC
359
- # returns a full batch (limit messages), immediately
360
- # re-fetch with the updated cursor to avoid dropping
361
- # overflow under high volume.
362
- # #458: cap drain iterations to prevent infinite loop if
363
- # since_id isn't being honored by AC.
364
- drain_iterations = 0
365
- MAX_DRAIN = 20
366
- while drain_iterations < MAX_DRAIN:
367
- drain_iterations += 1
368
- params = {"limit": 50}
369
- if _cursor["last_seen_id"]:
370
- params["since_id"] = _cursor["last_seen_id"]
371
- headers = {}
372
- if ac["token"]:
373
- headers["Authorization"] = f"Bearer {ac['token']}"
374
-
375
- resp = requests.get(
376
- f"{url}/api/messages",
377
- params=params,
378
- headers=headers,
379
- timeout=10,
380
- )
381
-
382
- if resp.status_code in (401, 403):
383
- log.warning("AC poll %d — re-registering", resp.status_code)
384
- try:
385
- ac_register(url)
386
- _seed_cursor_to_latest(url, cfg["cursor_file"])
387
- except Exception as exc:
388
- log.error("Re-register failed: %s", exc)
389
- break
390
-
391
- resp.raise_for_status()
392
-
393
- # #500: if we were failing and just recovered, seed cursor
394
- if poll_was_failing:
395
- poll_was_failing = False
396
- _seed_cursor_to_latest(url, cfg["cursor_file"])
397
- break # re-enter drain loop with fresh cursor
398
-
399
- messages = resp.json()
400
-
401
- if not isinstance(messages, list) or not messages:
402
- break
403
-
404
- # #458: detect stale responses — if every message ID in
405
- # the batch is <= our cursor, the server isn't honoring
406
- # since_id. Break to avoid re-forwarding.
407
- max_batch_id = max(m.get("id", 0) for m in messages)
408
- if max_batch_id <= _cursor["last_seen_id"]:
409
- log.warning("AC returned stale batch (max_id=%d <= cursor=%d) — breaking drain", max_batch_id, _cursor["last_seen_id"])
410
- break
411
-
412
- # #458: build echo names once per batch (inputs don't
413
- # change per-message).
414
- echo_names = ac["known_names"] | {
415
- bridge_sender,
416
- "discord-bridge",
417
- "discord_bridge",
418
- }
419
-
420
- for msg in messages:
421
- msg_id = msg.get("id", 0)
422
- sender = msg.get("sender", "")
423
- text = msg.get("text", "")
424
-
425
- # Helper: advance cursor and persist. Called after
426
- # a message is fully handled (skipped or forwarded)
427
- # so a crash can't replay it (#458). NOT called
428
- # before Discord delivery to avoid silent message
429
- # loss on transient send failures.
430
- def commit_cursor():
431
- if msg_id > _cursor["last_seen_id"]:
432
- _cursor["last_seen_id"] = msg_id
433
- save_cursor(cfg["cursor_file"])
434
-
435
- # Echo prevention: skip our own messages
436
- if sender in echo_names:
437
- commit_cursor()
438
- continue
439
-
440
- if not text:
441
- commit_cursor()
442
- continue
443
-
444
- # #501/#525: skip system/status noise and dedup.
445
- # When operator enables "Agents only", also filter
446
- # Loop guard and other edge-case noise.
447
- agents_only = _is_agents_only(cfg.get("project_id", ""))
448
- if not _should_forward(msg, agents_only=agents_only):
449
- commit_cursor()
450
- continue
451
-
452
- # #458: dedup guard — skip already-forwarded messages
453
- if msg_id in forwarded_ids:
454
- commit_cursor()
455
- continue
456
-
457
- # Forward to Discord
458
- try:
459
- discord_text = f"**{sender}**: {text}"
460
- # Discord message limit is 2000 chars
461
- if len(discord_text) > 2000:
462
- discord_text = discord_text[:1997] + "..."
463
- await channel.send(discord_text)
464
- # Only commit cursor + mark forwarded AFTER
465
- # successful Discord delivery.
466
- forwarded_ids.add(msg_id)
467
- _mark_forwarded(msg)
468
- commit_cursor()
469
- # Trim the set if it grows too large
470
- if len(forwarded_ids) > MAX_FORWARDED:
471
- sorted_ids = sorted(forwarded_ids)
472
- forwarded_ids.clear()
473
- forwarded_ids.update(sorted_ids[len(sorted_ids) // 2:])
474
- except Exception as exc:
475
- log.error("Failed to send to Discord: %s", exc)
476
-
477
- # If we got a full batch, there may be more — drain immediately
478
- if len(messages) >= 50:
479
- continue
480
- break
481
-
482
- if drain_iterations >= MAX_DRAIN:
483
- log.warning("Drain loop hit %d iterations — breaking to avoid infinite loop", MAX_DRAIN)
484
-
485
- except requests.RequestException as exc:
486
- log.warning("AC poll error: %s", exc)
487
- poll_was_failing = True
488
- except Exception as exc:
489
- log.error("Unexpected AC poll error: %s", exc)
490
- poll_was_failing = True
491
-
492
- await asyncio.sleep(interval)
493
-
494
-
495
- # ---------------------------------------------------------------------------
496
- # Discord → AC path
497
- # ---------------------------------------------------------------------------
498
-
499
- def send_to_ac(cfg, text, channel_name="general"):
500
- """Forward a message from Discord to AgentChattr."""
501
- url = cfg["agentchattr_url"]
502
- headers = {}
503
- if ac["token"]:
504
- headers["Authorization"] = f"Bearer {ac['token']}"
505
-
506
- try:
507
- resp = requests.post(
508
- f"{url}/api/send",
509
- json={
510
- "text": text,
511
- "channel": channel_name,
512
- "sender": cfg["bridge_sender"],
513
- },
514
- headers=headers,
515
- timeout=10,
516
- )
517
- if resp.status_code in (401, 403):
518
- log.warning("AC send %d — re-registering", resp.status_code)
519
- ac_register(url)
520
- # Retry once after re-register
521
- headers["Authorization"] = f"Bearer {ac['token']}"
522
- resp = requests.post(
523
- f"{url}/api/send",
524
- json={
525
- "text": text,
526
- "channel": channel_name,
527
- "sender": cfg["bridge_sender"],
528
- },
529
- headers=headers,
530
- timeout=10,
531
- )
532
- resp.raise_for_status()
533
- except requests.RequestException as exc:
534
- log.error("Failed to send to AC: %s", exc)
535
-
536
-
537
- # ---------------------------------------------------------------------------
538
- # Discord client
539
- # ---------------------------------------------------------------------------
540
-
541
- def create_client(cfg):
542
- """Create and configure the Discord client."""
543
- intents = discord.Intents.default()
544
- intents.message_content = True # Privileged — must be enabled in Developer Portal
545
- client = discord.Client(intents=intents)
546
- target_channel_id = cfg["channel_id"]
547
-
548
- @client.event
549
- async def on_ready():
550
- log.info("Discord bot logged in as %s (id=%s)", client.user, client.user.id)
551
- channel = client.get_channel(target_channel_id)
552
- if not channel:
553
- log.error(
554
- "Cannot find channel %s — check channel_id and bot permissions",
555
- target_channel_id,
556
- )
557
- return
558
- log.info("Monitoring Discord channel: #%s (%s)", channel.name, channel.id)
559
- # Start the AC → Discord poll loop
560
- client.loop.create_task(poll_ac_to_discord(cfg, channel))
561
-
562
- @client.event
563
- async def on_message(message):
564
- # Ignore own messages
565
- if message.author == client.user:
566
- return
567
- # Ignore other bots
568
- if message.author.bot:
569
- return
570
- # Only relay from the configured channel
571
- if message.channel.id != target_channel_id:
572
- return
573
-
574
- text = message.content
575
- if not text:
576
- # Warn about missing MESSAGE_CONTENT intent
577
- if not message.flags.value and not message.embeds and not message.attachments:
578
- log.warning(
579
- "Received message with empty content from %s — "
580
- "MESSAGE_CONTENT intent may not be enabled in the Developer Portal",
581
- message.author,
582
- )
583
- return
584
-
585
- # Prefix with Discord username for attribution
586
- ac_text = f"[discord:{message.author.display_name}] {text}"
587
- log.debug("Discord → AC: %s", ac_text[:100])
588
- send_to_ac(cfg, ac_text)
589
-
590
- return client
591
-
592
-
593
- # ---------------------------------------------------------------------------
594
- # Shutdown
595
- # ---------------------------------------------------------------------------
596
-
597
- _shutdown_done = []
598
-
599
-
600
- def shutdown(cfg):
601
- """Graceful shutdown: deregister from AC, save cursor."""
602
- if _shutdown_done:
603
- return
604
- _shutdown_done.append(True)
605
- log.info("Shutting down...")
606
- ac_deregister(cfg["agentchattr_url"])
607
- save_cursor(cfg["cursor_file"])
608
-
609
-
610
- # ---------------------------------------------------------------------------
611
- # Main
612
- # ---------------------------------------------------------------------------
613
-
614
- def main():
615
- parser = argparse.ArgumentParser(description="Discord ↔ AgentChattr bridge")
616
- parser.add_argument(
617
- "-c", "--config",
618
- help="Path to TOML config file (reads [discord] section)",
619
- )
620
- parser.add_argument(
621
- "-v", "--verbose",
622
- action="store_true",
623
- help="Enable debug logging",
624
- )
625
- args = parser.parse_args()
626
-
627
- logging.basicConfig(
628
- level=logging.DEBUG if args.verbose else logging.INFO,
629
- format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
630
- )
631
-
632
- cfg = load_config(args.config)
633
- validate_config(cfg)
634
-
635
- # Set bridge_sender so ac_register uses the configured base name
636
- ac["bridge_sender"] = cfg["bridge_sender"]
637
-
638
- # Load cursor
639
- load_cursor(cfg["cursor_file"])
640
-
641
- # Register with AgentChattr
642
- try:
643
- ac_register(cfg["agentchattr_url"])
644
- except Exception as exc:
645
- log.error("Initial AC registration failed: %s", exc)
646
- log.info("Will retry on first message send")
647
-
648
- # #500: seed cursor to latest on startup to avoid replaying history
649
- _seed_cursor_to_latest(cfg["agentchattr_url"], cfg["cursor_file"])
650
-
651
- # Start heartbeat
652
- start_heartbeat(cfg["agentchattr_url"], cfg["cursor_file"])
653
-
654
- # Register shutdown handlers
655
- atexit.register(shutdown, cfg)
656
- for sig in (signal.SIGINT, signal.SIGTERM):
657
- signal.signal(sig, lambda *_: (shutdown(cfg), sys.exit(0)))
658
-
659
- # Start Discord client
660
- client = create_client(cfg)
661
- log.info("Starting Discord bridge (channel_id=%s)", cfg["channel_id"])
662
- client.run(cfg["bot_token"], log_handler=None)
663
-
664
-
665
- if __name__ == "__main__":
666
- main()
@@ -1,2 +0,0 @@
1
- discord.py>=2.3
2
- requests>=2.28