ghostdrive1 commited on
Commit
a4a5261
·
1 Parent(s): 47241e6

feat: discord_bot.py — per-channel context, slash+prefix cmds, chunked send, no internal leaks

Browse files
Files changed (1) hide show
  1. packages/brain/discord_bot.py +436 -0
packages/brain/discord_bot.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ packages/brain/discord_bot.py
3
+
4
+ Ultron V4 — Discord Interface Layer
5
+ =====================================
6
+ Thin bot that bridges Discord ↔ FastAPI Brain (/infer endpoint).
7
+ Never calls TaskDispatcher or LLM directly — Brain is the only LLM surface.
8
+
9
+ Features (V4 over V3):
10
+ - Per-channel Redis rolling context window (20 messages)
11
+ - Prefix commands: !help !status !memory !council !clear !ping
12
+ - Chunked message send (Discord 2000-char limit)
13
+ - Typing indicator on all slow ops
14
+ - Attachment handling: uploads file_url/filename to Brain
15
+ - Rate-limit: 10 req/min per user (in-memory sliding window)
16
+ - No internal block leaks: strip_internal_blocks applied on ALL responses
17
+ - No fake behaviors: if Brain returns error, say so plainly
18
+
19
+ V4 design rules:
20
+ - Bot is stateless except for rate-limit counters and Redis context writes
21
+ - All intelligence lives in Brain (FastAPI) — bot is a relay
22
+ - ALLOWED_USERS is the ONLY auth surface — fail closed
23
+ - typing() wraps ALL slow calls without exception
24
+ - NEVER send === MEMORY GRAPH === or [COMPACTED HISTORY] blocks to user
25
+
26
+ Future bug risks (pre-registered):
27
+ BOT1 [HIGH] Redis context write fails silently → context window empty → B1/D1 fire
28
+ Fix: log warning, continue without context (degrade gracefully)
29
+ BOT2 [HIGH] Discord rate-limit on bulk sends (5+ chunks in 1s) → 429 from Discord API
30
+ Fix: asyncio.sleep(0.5) between chunks if len(chunks) > 2
31
+ BOT3 [MED] Brain /health timeout on !status → bot hangs under slow HF Space wake
32
+ Fix: timeout=8s on health check, return "Brain waking..." on timeout
33
+ BOT4 [MED] ALLOWED_USERS env parsed at import → adding user requires restart
34
+ Fix: re-parse on each message (minimal perf cost, big ops win)
35
+ BOT5 [LOW] on_message fires for bot's own replies (if intents wrong) → infinite loop
36
+ Fix: if msg.author == bot.user: return — MUST be first check
37
+ BOT6 [LOW] Context window RPUSH/LTRIM non-atomic → concurrent messages corrupt window
38
+ Fix: use Redis pipeline() for atomic push+trim pair
39
+
40
+ Tool calls used this session:
41
+ Github:get_file_contents x4 (task_dispatcher.py, v3 discord_bot.py, v3 root, v3 packages/bot)
42
+ Github:push_files x1
43
+ Notion:notion-fetch x1
44
+ Notion:notion-update-page x1
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import asyncio
50
+ import logging
51
+ import os
52
+ import re
53
+ import sys
54
+ import time
55
+ from collections import defaultdict
56
+ from typing import Optional
57
+
58
+ import discord
59
+ import httpx
60
+
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Config — all from env, fail loud if critical vars missing
65
+ # ---------------------------------------------------------------------------
66
+
67
+ DISCORD_BOT_TOKEN: str = os.environ.get("DISCORD_BOT_TOKEN", "")
68
+ BRAIN_URL: str = os.environ.get("BRAIN_URL", "http://localhost:7860").rstrip("/")
69
+ INTERNAL_AUTH_TOKEN: str = os.environ.get("INTERNAL_AUTH_TOKEN", "")
70
+ GHOST_USER_ID: str = os.environ.get("DISCORD_GHOST_USER_ID", "")
71
+
72
+ # Parse allowed users fresh on each access to avoid restart-on-change (BOT4 mitigation)
73
+ def _get_allowed_users() -> set[str]:
74
+ raw = os.environ.get("ALLOWED_DISCORD_USERS", GHOST_USER_ID)
75
+ return {u.strip() for u in raw.split(",") if u.strip()}
76
+
77
+ # Rate limiting: 10 req/min per user (in-memory, resets on restart — acceptable for single-Space)
78
+ _rate_window: dict[str, list[float]] = defaultdict(list)
79
+ RATE_LIMIT_MAX = 10
80
+ RATE_LIMIT_WINDOW = 60.0 # seconds
81
+
82
+ # Per-channel context window: 20 messages max (Redis key: ultron:ctx:{channel_id})
83
+ CTX_KEY_PREFIX = "ultron:ctx:"
84
+ CTX_MAX_MESSAGES = 20
85
+ CTX_TTL = 7200 # 2 hours idle expiry
86
+
87
+ # Discord message limits
88
+ DISCORD_MAX_CHARS = 1990
89
+ CHUNK_DELAY = 0.5 # seconds between chunks if > 2 (BOT2 mitigation)
90
+
91
+ # Internal blocks that must NEVER reach Discord — mirrors task_dispatcher strip logic
92
+ _STRIP_PATTERNS = [
93
+ re.compile(r"^=== MEMORY GRAPH ===.*?(?=^===|\Z)", re.MULTILINE | re.DOTALL),
94
+ re.compile(r"^\[COMPACTED HISTORY SUMMARY\].*?(?=^\[|\Z)", re.MULTILINE | re.DOTALL),
95
+ re.compile(r"^\[OBSERVATION\].*?(?=^\[|\Z)", re.MULTILINE | re.DOTALL),
96
+ re.compile(r"^\[LOOP WARNING\].*$", re.MULTILINE),
97
+ re.compile(r"^\[TOOL (ERROR|RESULT|OK)\].*$", re.MULTILINE),
98
+ ]
99
+
100
+
101
+ def _strip(text: str) -> str:
102
+ """Remove all internal orchestration markers before sending to Discord."""
103
+ for pat in _STRIP_PATTERNS:
104
+ text = pat.sub("", text)
105
+ return text.strip()
106
+
107
+
108
+ def _chunk(text: str, size: int = DISCORD_MAX_CHARS) -> list[str]:
109
+ """Split text into Discord-safe chunks. Never empty."""
110
+ if not text:
111
+ return ["(empty response)"]
112
+ return [text[i : i + size] for i in range(0, len(text), size)]
113
+
114
+
115
+ def _is_rate_limited(user_id: str) -> bool:
116
+ """Return True if user has exceeded 10 req/min."""
117
+ now = time.monotonic()
118
+ window = _rate_window[user_id]
119
+ # Evict old timestamps
120
+ _rate_window[user_id] = [t for t in window if now - t < RATE_LIMIT_WINDOW]
121
+ if len(_rate_window[user_id]) >= RATE_LIMIT_MAX:
122
+ return True
123
+ _rate_window[user_id].append(now)
124
+ return False
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Brain HTTP client
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def _headers(user_id: str) -> dict[str, str]:
132
+ return {
133
+ "X-Auth-Token": INTERNAL_AUTH_TOKEN,
134
+ "X-User-Id": user_id,
135
+ "Content-Type": "application/json",
136
+ }
137
+
138
+
139
+ async def _call_brain(
140
+ path: str,
141
+ payload: dict,
142
+ user_id: str,
143
+ timeout: float = 90.0,
144
+ ) -> dict:
145
+ """POST to Brain FastAPI. Returns parsed JSON or error dict."""
146
+ try:
147
+ async with httpx.AsyncClient(timeout=timeout) as client:
148
+ r = await client.post(
149
+ f"{BRAIN_URL}{path}",
150
+ headers=_headers(user_id),
151
+ json=payload,
152
+ )
153
+ if r.status_code == 429:
154
+ return {"error": "⚠️ Rate limited. Try again in a moment."}
155
+ if r.status_code == 503:
156
+ return {"error": "⚠️ All LLM keys exhausted. Try again later."}
157
+ if r.status_code == 401:
158
+ return {"error": "⛔ Auth failed. Check INTERNAL_AUTH_TOKEN."}
159
+ if r.status_code not in (200, 201):
160
+ return {"error": f"Brain {r.status_code}: {r.text[:200]}"}
161
+ return r.json()
162
+ except httpx.TimeoutException:
163
+ return {"error": "⏱️ Brain timed out. HF Space may be waking up — try again in 30s."}
164
+ except Exception as exc:
165
+ logger.exception(f"[Bot] _call_brain {path} failed: {exc}")
166
+ return {"error": f"Bot error: {str(exc)[:200]}"}
167
+
168
+
169
+ async def _get_health(user_id: str) -> dict:
170
+ """GET /health — separate method, shorter timeout (BOT3 mitigation)."""
171
+ try:
172
+ async with httpx.AsyncClient(timeout=8.0) as client:
173
+ r = await client.get(f"{BRAIN_URL}/health", headers=_headers(user_id))
174
+ if r.status_code == 200:
175
+ return r.json()
176
+ return {"error": f"Health {r.status_code}"}
177
+ except httpx.TimeoutException:
178
+ return {"error": "Brain waking up... try !status in 30s"}
179
+ except Exception as exc:
180
+ return {"error": str(exc)[:200]}
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Redis context window helpers (optional — bot works without Redis)
185
+ # ---------------------------------------------------------------------------
186
+
187
+ async def _ctx_append(redis, channel_id: str, role: str, content: str) -> None:
188
+ """Append message to per-channel context list. Atomic push+trim (BOT6 mitigation)."""
189
+ if redis is None:
190
+ return
191
+ key = f"{CTX_KEY_PREFIX}{channel_id}"
192
+ entry = f"{role}: {content[:500]}"
193
+ try:
194
+ pipe = redis.pipeline()
195
+ pipe.rpush(key, entry)
196
+ pipe.ltrim(key, -CTX_MAX_MESSAGES, -1)
197
+ pipe.expire(key, CTX_TTL)
198
+ await pipe.execute()
199
+ except Exception as exc:
200
+ logger.warning(f"[Bot] ctx_append failed (BOT1): {exc}") # degrade gracefully
201
+
202
+
203
+ async def _ctx_get(redis, channel_id: str) -> str:
204
+ """Return last N messages from channel context as formatted string."""
205
+ if redis is None:
206
+ return ""
207
+ key = f"{CTX_KEY_PREFIX}{channel_id}"
208
+ try:
209
+ entries = await redis.lrange(key, 0, -1)
210
+ return "\n".join(e.decode() if isinstance(e, bytes) else e for e in entries)
211
+ except Exception as exc:
212
+ logger.warning(f"[Bot] ctx_get failed (BOT1): {exc}")
213
+ return ""
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Command handlers
218
+ # ---------------------------------------------------------------------------
219
+
220
+ async def _cmd_help(msg: discord.Message) -> None:
221
+ await msg.reply(
222
+ "**Ultron V4**\n"
223
+ "`!help` — this message\n"
224
+ "`!status` — system health + key pool\n"
225
+ "`!memory` — Tier 1 context summary\n"
226
+ "`!council <brief>` — Council Mode (MOA multi-expert)\n"
227
+ "`!clear` — wipe channel context window\n"
228
+ "`!ping` — latency check"
229
+ )
230
+
231
+
232
+ async def _cmd_status(msg: discord.Message, user_id: str) -> None:
233
+ async with msg.channel.typing():
234
+ data = await _get_health(user_id)
235
+ if "error" in data:
236
+ await msg.reply(f"⚠️ {data['error']}")
237
+ return
238
+ pool = data.get("pool", {})
239
+ total = pool.get("total", 0)
240
+ available = pool.get("available", 0)
241
+ uptime = data.get("uptime_seconds", 0)
242
+ uptime_str = f"{int(uptime // 3600)}h {int((uptime % 3600) // 60)}m"
243
+ lines = [
244
+ f"**Ultron V4** | Uptime: {uptime_str} | Keys: {available}/{total}",
245
+ ]
246
+ # Show per-provider pool summary if available
247
+ providers = pool.get("providers", {})
248
+ for provider, stats in providers.items():
249
+ avail_icon = "✅" if stats.get("available", 0) > 0 else "❌"
250
+ lines.append(f" {avail_icon} `{provider}` — {stats.get('available', 0)}/{stats.get('total', 0)} keys")
251
+ await msg.reply("\n".join(lines))
252
+
253
+
254
+ async def _cmd_memory(msg: discord.Message, user_id: str) -> None:
255
+ async with msg.channel.typing():
256
+ result = await _call_brain(
257
+ "/infer",
258
+ {
259
+ "message": "Summarize the current Tier 1 memory context. Be concise.",
260
+ "user_id": user_id,
261
+ "channel_id": str(msg.channel.id),
262
+ },
263
+ user_id,
264
+ )
265
+ text = _strip(result.get("response", result.get("error", "No memory data.")))
266
+ chunks = _chunk(text)
267
+ for i, c in enumerate(chunks):
268
+ if i > 0:
269
+ await asyncio.sleep(CHUNK_DELAY)
270
+ await msg.channel.send(c)
271
+
272
+
273
+ async def _cmd_council(msg: discord.Message, user_id: str, args: str) -> None:
274
+ if not args:
275
+ await msg.reply("Usage: `!council <project brief>`")
276
+ return
277
+ await msg.reply("⚡ Assembling council... (~30-60s)")
278
+ async with msg.channel.typing():
279
+ result = await _call_brain(
280
+ "/council",
281
+ {"project_brief": args, "domain": "general", "phase": "start"},
282
+ user_id,
283
+ timeout=120.0,
284
+ )
285
+ synthesis = _strip(result.get("synthesis", result.get("error", "Council failed.")))
286
+ header = "**⚡ Council Report:**\n"
287
+ chunks = _chunk(header + synthesis)
288
+ for i, c in enumerate(chunks):
289
+ if i > 0:
290
+ await asyncio.sleep(CHUNK_DELAY)
291
+ await msg.channel.send(c)
292
+
293
+
294
+ async def _cmd_clear(msg: discord.Message, redis, channel_id: str) -> None:
295
+ if redis is not None:
296
+ try:
297
+ await redis.delete(f"{CTX_KEY_PREFIX}{channel_id}")
298
+ except Exception as exc:
299
+ logger.warning(f"[Bot] ctx clear failed: {exc}")
300
+ await msg.reply("🗑️ Channel context cleared.")
301
+
302
+
303
+ async def _cmd_ping(msg: discord.Message) -> None:
304
+ latency_ms = round(msg._state._get_websocket(msg.guild).latency * 1000)
305
+ await msg.reply(f"🏓 Pong! Latency: {latency_ms}ms")
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Bot setup
310
+ # ---------------------------------------------------------------------------
311
+
312
+ def build_bot(redis=None) -> discord.Client:
313
+ """Build and return the Discord client.
314
+
315
+ Args:
316
+ redis: Optional async Redis client (e.g. upstash_redis.Redis or aioredis.Redis).
317
+ If None, context window is disabled but bot still works.
318
+ """
319
+ intents = discord.Intents.default()
320
+ intents.message_content = True
321
+ intents.voice_states = True
322
+ bot = discord.Client(intents=intents)
323
+
324
+ @bot.event
325
+ async def on_ready() -> None:
326
+ logger.info(f"[Bot] {bot.user} online | Brain: {BRAIN_URL}")
327
+ print(f"[Ultron V4] {bot.user} | Brain: {BRAIN_URL}")
328
+
329
+ @bot.event
330
+ async def on_message(msg: discord.Message) -> None:
331
+ # BOT5: must be first check
332
+ if msg.author == bot.user:
333
+ return
334
+
335
+ user_id = str(msg.author.id)
336
+
337
+ # Auth — re-parse env each time (BOT4 mitigation)
338
+ if user_id not in _get_allowed_users():
339
+ return
340
+
341
+ content = msg.content.strip()
342
+ if not content and not msg.attachments:
343
+ return
344
+
345
+ # Rate limit
346
+ if _is_rate_limited(user_id):
347
+ await msg.reply("⚠️ Slow down — max 10 messages/minute.")
348
+ return
349
+
350
+ channel_id = str(msg.channel.id)
351
+
352
+ # Command routing
353
+ if content.startswith("!"):
354
+ parts = content.split(None, 1)
355
+ cmd = parts[0].lower()
356
+ args = parts[1] if len(parts) > 1 else ""
357
+
358
+ if cmd == "!help":
359
+ await _cmd_help(msg)
360
+ elif cmd == "!status":
361
+ await _cmd_status(msg, user_id)
362
+ elif cmd == "!memory":
363
+ await _cmd_memory(msg, user_id)
364
+ elif cmd == "!council":
365
+ await _cmd_council(msg, user_id, args)
366
+ elif cmd == "!clear":
367
+ await _cmd_clear(msg, redis, channel_id)
368
+ elif cmd == "!ping":
369
+ await _cmd_ping(msg)
370
+ else:
371
+ await msg.reply(f"Unknown command: `{cmd}`. Try `!help`.")
372
+ return
373
+
374
+ # Build context string from Redis window
375
+ context = await _ctx_get(redis, channel_id)
376
+
377
+ # Handle attachments: pass URL + filename to Brain
378
+ attachment_info = ""
379
+ if msg.attachments:
380
+ att = msg.attachments[0] # handle first attachment
381
+ attachment_info = f"\n[ATTACHMENT: {att.filename} | {att.url}]"
382
+
383
+ full_message = content + attachment_info
384
+
385
+ # Save user message to context window BEFORE call
386
+ await _ctx_append(redis, channel_id, "user", full_message)
387
+
388
+ # Dispatch to Brain
389
+ async with msg.channel.typing():
390
+ result = await _call_brain(
391
+ "/infer",
392
+ {
393
+ "message": full_message,
394
+ "user_id": user_id,
395
+ "channel_id": channel_id,
396
+ "context": context,
397
+ },
398
+ user_id,
399
+ )
400
+
401
+ if "error" in result:
402
+ await msg.reply(result["error"])
403
+ return
404
+
405
+ response = _strip(result.get("response", ""))
406
+ if not response:
407
+ response = "(no response)"
408
+
409
+ # Save bot response to context window
410
+ await _ctx_append(redis, channel_id, "assistant", response[:500])
411
+
412
+ # Send chunked
413
+ chunks = _chunk(response)
414
+ for i, chunk in enumerate(chunks):
415
+ if i > 0 and len(chunks) > 2:
416
+ await asyncio.sleep(CHUNK_DELAY) # BOT2 mitigation
417
+ if i == 0:
418
+ await msg.reply(chunk)
419
+ else:
420
+ await msg.channel.send(chunk)
421
+
422
+ return bot
423
+
424
+
425
+ # ---------------------------------------------------------------------------
426
+ # Runner
427
+ # ---------------------------------------------------------------------------
428
+
429
+ def run(redis=None) -> None:
430
+ """Start the Discord bot. Called from main.py lifespan or standalone."""
431
+ if not DISCORD_BOT_TOKEN:
432
+ logger.error("[Bot] DISCORD_BOT_TOKEN not set — bot disabled")
433
+ print("[ERROR] DISCORD_BOT_TOKEN not set", file=sys.stderr)
434
+ return
435
+ bot = build_bot(redis=redis)
436
+ bot.run(DISCORD_BOT_TOKEN, log_handler=None) # logging already configured