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.
- package/README.md +19 -35
- package/bin/quadwork.js +48 -1118
- package/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +3 -3
- package/out/__next._full.txt +14 -14
- package/out/__next._head.txt +4 -4
- package/out/__next._index.txt +8 -8
- package/out/__next._tree.txt +2 -2
- package/out/_next/static/chunks/{030cjkhts487t.js → 079wdniva~de1.js} +1 -1
- package/out/_next/static/chunks/{0n~dq4kpx9xxx.js → 07lhk_q6pmm3r.js} +1 -1
- package/out/_next/static/chunks/0_79hkefw1mo2.js +1 -0
- package/out/_next/static/chunks/{153f.fj8jlvle.js → 0_lyyn..t63bc.js} +1 -1
- package/out/_next/static/chunks/0oxv9vrvc17to.js +2 -0
- package/out/_next/static/chunks/0py7102i226n5.js +1 -0
- package/out/_next/static/chunks/{13fv-yi7.v52g.js → 0q4bm04c1jl_3.js} +1 -1
- package/out/_next/static/chunks/{0_idxioyl0p7h.js → 0sjhy6oe3mbon.js} +1 -1
- package/out/_next/static/chunks/13xk0vgfbrcld.css +2 -0
- package/out/_next/static/chunks/14k3bfe537f9_.js +25 -0
- package/out/_next/static/chunks/{turbopack-0qm-e3ifrz~2u.js → turbopack-0y2u-q0l2m67w.js} +1 -1
- package/out/_not-found/__next._full.txt +13 -13
- package/out/_not-found/__next._head.txt +4 -4
- package/out/_not-found/__next._index.txt +8 -8
- package/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
- package/out/_not-found/__next._not-found.txt +3 -3
- package/out/_not-found/__next._tree.txt +2 -2
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +13 -13
- package/out/app-shell/__next._full.txt +13 -13
- package/out/app-shell/__next._head.txt +4 -4
- package/out/app-shell/__next._index.txt +8 -8
- package/out/app-shell/__next._tree.txt +2 -2
- package/out/app-shell/__next.app-shell.__PAGE__.txt +2 -2
- package/out/app-shell/__next.app-shell.txt +3 -3
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +13 -13
- package/out/index.html +1 -1
- package/out/index.txt +14 -14
- package/out/project/_/__next._full.txt +14 -14
- package/out/project/_/__next._head.txt +4 -4
- package/out/project/_/__next._index.txt +8 -8
- package/out/project/_/__next._tree.txt +2 -2
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +3 -3
- package/out/project/_/__next.project.$d$id.txt +3 -3
- package/out/project/_/__next.project.txt +3 -3
- package/out/project/_/queue/__next._full.txt +14 -14
- package/out/project/_/queue/__next._head.txt +4 -4
- package/out/project/_/queue/__next._index.txt +8 -8
- package/out/project/_/queue/__next._tree.txt +2 -2
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.queue.txt +3 -3
- package/out/project/_/queue/__next.project.$d$id.txt +3 -3
- package/out/project/_/queue/__next.project.txt +3 -3
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +14 -14
- package/out/project/_.html +1 -1
- package/out/project/_.txt +14 -14
- package/out/settings/__next._full.txt +14 -14
- package/out/settings/__next._head.txt +4 -4
- package/out/settings/__next._index.txt +8 -8
- package/out/settings/__next._tree.txt +2 -2
- package/out/settings/__next.settings.__PAGE__.txt +3 -3
- package/out/settings/__next.settings.txt +3 -3
- package/out/settings.html +1 -1
- package/out/settings.txt +14 -14
- package/out/setup/__next._full.txt +14 -14
- package/out/setup/__next._head.txt +4 -4
- package/out/setup/__next._index.txt +8 -8
- package/out/setup/__next._tree.txt +2 -2
- package/out/setup/__next.setup.__PAGE__.txt +3 -3
- package/out/setup/__next.setup.txt +3 -3
- package/out/setup.html +1 -1
- package/out/setup.txt +14 -14
- package/package.json +4 -2
- package/server/ac-restore.js +128 -0
- package/server/bridges/discord.js +183 -0
- package/server/bridges/telegram.js +210 -0
- package/server/config.js +4 -60
- package/server/file-chat.js +318 -0
- package/server/index.js +173 -1286
- package/server/install-agentchattr.js +3 -284
- package/server/mcp-chat-shim.js +171 -0
- package/server/migrate-ac.js +158 -0
- package/server/pty-dispatcher.js +188 -0
- package/server/routes.js +149 -1397
- package/templates/CLAUDE.md +2 -2
- package/templates/OVERNIGHT-QUEUE.md +1 -1
- package/templates/seeds/butler.CLAUDE.md +30 -62
- package/templates/seeds/dev.AGENTS.md +10 -1
- package/templates/seeds/head.AGENTS.md +3 -3
- package/templates/seeds/re1.AGENTS.md +3 -3
- package/templates/seeds/re2.AGENTS.md +3 -3
- package/bridges/discord/__pycache__/discord_bridge.cpython-314.pyc +0 -0
- package/bridges/discord/discord_bridge.py +0 -666
- package/bridges/discord/requirements.txt +0 -2
- package/out/_next/static/chunks/0_bb~2.5h2ntm.css +0 -2
- package/out/_next/static/chunks/0makcdqkwobp6.js +0 -25
- package/out/_next/static/chunks/0uz5svjlo9dwl.js +0 -1
- package/out/_next/static/chunks/0zahstmgdrpy5.js +0 -1
- package/out/_next/static/chunks/0zfotsowwll1x.js +0 -2
- package/server/__tests__/bridge-auto-stop-guard.test.js +0 -134
- package/server/__tests__/rate-limit-handling.test.js +0 -168
- package/server/__tests__/scrub-secrets.test.js +0 -235
- package/server/__tests__/v1110-security-qa.test.js +0 -312
- package/server/agentchattr-registry.js +0 -188
- package/server/install-agentchattr.patchCrashTimeout.test.js +0 -71
- package/server/queue-watcher.js +0 -171
- package/server/queue-watcher.test.js +0 -64
- package/server/routes.batchProgress.test.js +0 -94
- package/server/routes.chatWsSend.test.js +0 -161
- package/server/routes.discordBridge.test.js +0 -80
- package/server/routes.parseActiveBatch.test.js +0 -88
- package/server/routes.telegramBridge.test.js +0 -241
- package/templates/config.toml +0 -72
- package/templates/wrapper.py +0 -70
- /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_buildManifest.js +0 -0
- /package/out/_next/static/{K7A3YZrh4sLaRRP1-Lq7v → 479UD5Kit4YvCmtgO25VT}/_clientMiddlewareManifest.js +0 -0
- /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()
|