tsugite-discord 0.17.0__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.
@@ -0,0 +1,184 @@
1
+ # ---> Python
2
+ # Byte-compiled / optimized / DLL files
3
+ __pycache__/
4
+ *.py[cod]
5
+ *$py.class
6
+
7
+ # C extensions
8
+ *.so
9
+
10
+ # Distribution / packaging
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ MANIFEST
29
+
30
+ # PyInstaller
31
+ # Usually these files are written by a python script from a template
32
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
33
+ *.manifest
34
+ *.spec
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .nox/
44
+ .coverage
45
+ .coverage.*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ *.cover
50
+ *.py,cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ #Pipfile.lock
97
+
98
+ # poetry
99
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
103
+ #poetry.lock
104
+
105
+ # pdm
106
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
107
+ #pdm.lock
108
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
109
+ # in version control.
110
+ # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
111
+ .pdm.toml
112
+ .pdm-python
113
+ .pdm-build/
114
+
115
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
116
+ __pypackages__/
117
+
118
+ # Celery stuff
119
+ celerybeat-schedule
120
+ celerybeat.pid
121
+
122
+ # SageMath parsed files
123
+ *.sage.py
124
+
125
+ # Environments
126
+ .env
127
+ .venv
128
+ env/
129
+ venv/
130
+ ENV/
131
+ env.bak/
132
+ venv.bak/
133
+
134
+ # Spyder project settings
135
+ .spyderproject
136
+ .spyproject
137
+
138
+ # Rope project settings
139
+ .ropeproject
140
+
141
+ # mkdocs documentation
142
+ /site
143
+
144
+ # mypy
145
+ .mypy_cache/
146
+ .dmypy.json
147
+ dmypy.json
148
+
149
+ # Pyre type checker
150
+ .pyre/
151
+
152
+ # pytype static type analyzer
153
+ .pytype/
154
+
155
+ # Cython debug symbols
156
+ cython_debug/
157
+
158
+ # PyCharm
159
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
160
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
161
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
162
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
163
+ #.idea/
164
+
165
+ .env
166
+ .env
167
+ benchmark_results/
168
+ test_output/
169
+ .claude/settings.local.json
170
+ std*.txt
171
+ secrets/*
172
+
173
+
174
+ # TODO: temp - I need to clean up the docs
175
+ docs-old/
176
+ docs/design/
177
+ examples/*
178
+ !examples/tsugite-example-plugin/
179
+ agents/
180
+ .claude/
181
+ .tsugite/
182
+ benchmarks/
183
+ docker-compose.test.yml
184
+ #### TODO ^^^
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: tsugite-discord
3
+ Version: 0.17.0
4
+ Summary: Tsugite plugin: Discord bot adapter for the daemon
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: discord-py>=2.3.2
7
+ Requires-Dist: tsugite-daemon==0.17.0
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "tsugite-discord"
3
+ version = "0.17.0"
4
+ description = "Tsugite plugin: Discord bot adapter for the daemon"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "tsugite-daemon==0.17.0",
8
+ "discord.py>=2.3.2",
9
+ ]
10
+
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [tool.hatch.build.targets.wheel]
16
+ packages = ["tsugite_discord"]
17
+
18
+ [tool.hatch.build.targets.sdist]
19
+ include = [
20
+ "/tsugite_discord",
21
+ "/pyproject.toml",
22
+ ]
23
+
24
+ [tool.uv.sources]
25
+ tsugite-daemon = { workspace = true }
@@ -0,0 +1,919 @@
1
+ """Discord bot adapter."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from types import SimpleNamespace
9
+ from typing import NamedTuple, Optional
10
+
11
+ import discord
12
+ from discord.ext import commands
13
+ from tsugite_daemon.adapters.base import BaseAdapter, ChannelContext, CompositeUIHandler, SSEBroadcastHandler
14
+ from tsugite_daemon.config import AgentConfig, DiscordBotConfig
15
+ from tsugite_daemon.session_store import Session, SessionSource, SessionStore
16
+
17
+ from tsugite.events import (
18
+ CodeExecutionEvent,
19
+ ErrorEvent,
20
+ FinalAnswerEvent,
21
+ InfoEvent,
22
+ LLMMessageEvent,
23
+ LLMWaitProgressEvent,
24
+ ObservationEvent,
25
+ ReactionEvent,
26
+ ReasoningContentEvent,
27
+ StepStartEvent,
28
+ ToolCallEvent,
29
+ ToolResultEvent,
30
+ WarningEvent,
31
+ )
32
+ from tsugite.events.base import BaseEvent
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class ProgressStep(NamedTuple):
38
+ """Progress update step."""
39
+
40
+ label: str
41
+ completed: bool
42
+ emoji: str
43
+
44
+
45
+ def _handle_async_exception(future_or_task, context: str = "") -> None:
46
+ """Handle exceptions from Tasks or Futures to prevent silent crashes."""
47
+ try:
48
+ future_or_task.result()
49
+ except asyncio.CancelledError:
50
+ pass
51
+ except Exception as e:
52
+ prefix = f"[{context}] " if context else ""
53
+ logger.error("%sAsync error: %s", prefix, e, exc_info=True)
54
+
55
+
56
+ class DiscordProgressHandler:
57
+ """Lightweight handler for Discord progress updates."""
58
+
59
+ MAX_DISPLAY_STEPS = 10 # Show last N steps if too many
60
+
61
+ def __init__(
62
+ self,
63
+ channel: discord.abc.Messageable,
64
+ loop: asyncio.AbstractEventLoop,
65
+ trigger_message: Optional[discord.Message] = None,
66
+ header_text: Optional[str] = None,
67
+ ):
68
+ """Initialize progress handler.
69
+
70
+ Args:
71
+ channel: Discord channel to send progress updates to
72
+ loop: Discord bot's event loop (for thread-safe scheduling)
73
+ trigger_message: The user message that triggered this interaction (for reactions)
74
+ header_text: First line of the progress tree, used to surface the active session
75
+ """
76
+ self.channel = channel
77
+ self.loop = loop
78
+ self.trigger_message = trigger_message
79
+ self.header_text = header_text or "🤔 Working..."
80
+ self.progress_msg: Optional[discord.Message] = None
81
+ self.updates: list[ProgressStep] = []
82
+ self.update_lock = asyncio.Lock()
83
+ self.typing_task: Optional[asyncio.Task] = None
84
+ self.done = False
85
+ self._reasoning_shown = False
86
+ self._waiting_step_idx: Optional[int] = None
87
+ self._last_rendered: Optional[str] = None
88
+ self._thought_buffer: Optional[str] = None
89
+ self._current_turn = 0
90
+ self._max_turns = 10
91
+
92
+ def _complete_prev_and_append(self, label: str, emoji: str) -> None:
93
+ if self.updates:
94
+ prev = self.updates[-1]
95
+ self.updates[-1] = ProgressStep(prev.label, True, prev.emoji)
96
+ self.updates.append(ProgressStep(label, False, emoji))
97
+
98
+ def _mark_last_complete(self) -> bool:
99
+ if self.updates and not self.updates[-1].completed:
100
+ last = self.updates[-1]
101
+ self.updates[-1] = ProgressStep(last.label, True, last.emoji)
102
+ return True
103
+ return False
104
+
105
+ async def _flush_thought(self) -> None:
106
+ """Send the buffered LLM thought prose as a standalone Discord message."""
107
+ if not self._thought_buffer:
108
+ return
109
+ text = self._thought_buffer
110
+ self._thought_buffer = None
111
+
112
+ msg = f"💭 {text}"
113
+ limit = 2000
114
+ if len(msg) > limit:
115
+ suffix = "\n... (truncated)"
116
+ msg = msg[: limit - len(suffix)] + suffix
117
+
118
+ try:
119
+ await self.channel.send(msg)
120
+ except discord.errors.HTTPException as e:
121
+ logger.debug("Failed to send thought: %s", e)
122
+
123
+ def handle_event(self, event: BaseEvent) -> None:
124
+ """Handle EventBus events and update Discord message.
125
+
126
+ This is called from a different thread (executor), so we use
127
+ run_coroutine_threadsafe to schedule work on the Discord event loop.
128
+ """
129
+ future = asyncio.run_coroutine_threadsafe(self._handle_event_async(event), self.loop)
130
+ future.add_done_callback(lambda f: _handle_async_exception(f, "progress"))
131
+
132
+ def _emit(self, event_type: str, data: dict) -> None:
133
+ """Handle progress events from the base adapter (e.g. compacting/compacted)."""
134
+ if event_type == "compacting":
135
+ self.updates.append(ProgressStep("Compacting history", False, "📦"))
136
+ elif event_type == "compacting_waiting":
137
+ self.updates.append(ProgressStep("Waiting for compaction", False, "⌛"))
138
+ elif event_type == "compacted":
139
+ if self.updates and self.updates[-1].emoji in {"📦", "⌛"} and not self.updates[-1].completed:
140
+ last = self.updates[-1]
141
+ self.updates[-1] = ProgressStep(last.label, True, last.emoji)
142
+ else:
143
+ return
144
+ future = asyncio.run_coroutine_threadsafe(self._update_progress(), self.loop)
145
+ future.add_done_callback(lambda f: _handle_async_exception(f, "emit"))
146
+
147
+ async def _handle_event_async(self, event: BaseEvent) -> None:
148
+ """Async implementation of event handling.
149
+
150
+ Labels mirror _progress_status_text in tsugite/daemon/session_store.py so the
151
+ Discord progress message shows the same status text the web UI sidebar shows.
152
+ """
153
+ async with self.update_lock:
154
+ if not isinstance(event, LLMWaitProgressEvent):
155
+ self._waiting_step_idx = None
156
+
157
+ if isinstance(event, StepStartEvent):
158
+ self._current_turn = event.step
159
+ self._max_turns = event.max_turns
160
+ self._complete_prev_and_append(f"Turn {event.step}/{event.max_turns}", "🤔")
161
+ await self._update_progress()
162
+
163
+ elif isinstance(event, CodeExecutionEvent):
164
+ await self._flush_thought()
165
+ self._complete_prev_and_append("Running code", "⚙️")
166
+ await self._update_progress()
167
+
168
+ elif isinstance(event, ToolCallEvent):
169
+ await self._flush_thought()
170
+ self._complete_prev_and_append(f"Tool: {event.tool_name}", "🔧")
171
+ await self._update_progress()
172
+
173
+ elif isinstance(event, (ObservationEvent, ToolResultEvent)):
174
+ if self._mark_last_complete():
175
+ await self._update_progress()
176
+
177
+ elif isinstance(event, LLMMessageEvent):
178
+ if event.content:
179
+ self._thought_buffer = event.content
180
+
181
+ elif isinstance(event, ReasoningContentEvent):
182
+ if not self._reasoning_shown:
183
+ self._reasoning_shown = True
184
+ self.updates.append(ProgressStep("Reasoning", False, "💭"))
185
+ await self._update_progress()
186
+
187
+ elif isinstance(event, LLMWaitProgressEvent):
188
+ label = f"Waiting on LLM ({event.elapsed_seconds}s)"
189
+ if self._waiting_step_idx is not None:
190
+ self.updates[self._waiting_step_idx] = ProgressStep(label, False, "⏳")
191
+ else:
192
+ self.updates.append(ProgressStep(label, False, "⏳"))
193
+ self._waiting_step_idx = len(self.updates) - 1
194
+ await self._update_progress()
195
+
196
+ elif isinstance(event, WarningEvent):
197
+ self.updates.append(ProgressStep("Retrying", False, "⚠️"))
198
+ await self._update_progress()
199
+
200
+ elif isinstance(event, ErrorEvent):
201
+ self.updates.append(ProgressStep("Error", False, "❌"))
202
+ await self._update_progress()
203
+
204
+ elif isinstance(event, InfoEvent):
205
+ if event.message:
206
+ try:
207
+ await self.channel.send(event.message)
208
+ except discord.errors.HTTPException as e:
209
+ logger.debug("Failed to send info message: %s", e)
210
+
211
+ elif isinstance(event, ReactionEvent):
212
+ target = self.trigger_message
213
+ if target and event.emoji:
214
+ try:
215
+ await target.add_reaction(event.emoji)
216
+ except discord.errors.HTTPException as e:
217
+ logger.debug("Failed to add reaction %s: %s", event.emoji, e)
218
+
219
+ elif isinstance(event, FinalAnswerEvent):
220
+ self._thought_buffer = None
221
+ self._mark_last_complete()
222
+ await self._collapse_to_summary()
223
+
224
+ async def _update_progress(self):
225
+ """Update or create progress message."""
226
+ if self.done:
227
+ return
228
+
229
+ lines = [self.header_text]
230
+
231
+ display_updates = self.updates
232
+ if len(self.updates) > self.MAX_DISPLAY_STEPS:
233
+ skipped = len(self.updates) - self.MAX_DISPLAY_STEPS
234
+ lines.append(f"├─ ... ({skipped} earlier steps)")
235
+ display_updates = self.updates[-self.MAX_DISPLAY_STEPS :]
236
+
237
+ for i, step in enumerate(display_updates):
238
+ is_last = i == len(display_updates) - 1
239
+ prefix = "└─" if is_last else "├─"
240
+ suffix = " ✓" if step.completed else ""
241
+ lines.append(f"{prefix} {step.emoji} {step.label}{suffix}")
242
+
243
+ text = "\n".join(lines)
244
+
245
+ if len(text) > 2000:
246
+ text = text[:1950] + "\n... (truncated)"
247
+
248
+ if text == self._last_rendered:
249
+ return
250
+
251
+ try:
252
+ if self.progress_msg is None:
253
+ self.progress_msg = await self.channel.send(text)
254
+ else:
255
+ await self.progress_msg.edit(content=text)
256
+ self._last_rendered = text
257
+ except discord.errors.HTTPException:
258
+ pass # Ignore errors (rate limit, deleted message, etc.)
259
+
260
+ async def _collapse_to_summary(self):
261
+ """Collapse progress to a summary on completion."""
262
+ self.done = True
263
+
264
+ if not self.progress_msg or not self.updates:
265
+ return
266
+
267
+ turn_count = sum(1 for u in self.updates if u.label.startswith("Turn "))
268
+ if turn_count == 0:
269
+ turn_count = self._current_turn or 1
270
+
271
+ summary = f"✅ Done ({turn_count} turn{'s' if turn_count != 1 else ''})"
272
+
273
+ try:
274
+ await self.progress_msg.edit(content=summary)
275
+ except discord.errors.HTTPException:
276
+ pass
277
+
278
+ async def start_typing_loop(self):
279
+ """Keep typing indicator active for long operations."""
280
+
281
+ async def typing_loop():
282
+ try:
283
+ while not self.done:
284
+ try:
285
+ await self.channel.typing()
286
+ except (AttributeError, discord.errors.HTTPException):
287
+ pass # Some channel types may not support typing
288
+ await asyncio.sleep(8) # Re-trigger before 10s expiry
289
+ except asyncio.CancelledError:
290
+ pass
291
+ except Exception as e:
292
+ logger.debug("Typing loop error: %s", e)
293
+
294
+ self.typing_task = asyncio.create_task(typing_loop())
295
+
296
+ async def cleanup(self, success: bool = True):
297
+ """Clean up and finalize status message."""
298
+ self.done = True
299
+
300
+ if self.typing_task:
301
+ self.typing_task.cancel()
302
+ try:
303
+ await self.typing_task
304
+ except asyncio.CancelledError:
305
+ pass
306
+
307
+ if self.progress_msg and not success:
308
+ try:
309
+ turn_count = sum(1 for u in self.updates if u.label.startswith("Turn "))
310
+ if turn_count == 0:
311
+ turn_count = self._current_turn or 1
312
+ await self.progress_msg.edit(
313
+ content=f"❌ Error after {turn_count} turn{'s' if turn_count != 1 else ''}"
314
+ )
315
+ except discord.errors.HTTPException:
316
+ pass
317
+ elif success and self.updates and self.progress_msg:
318
+ await self._collapse_to_summary()
319
+
320
+
321
+ class _YesNoView(discord.ui.View):
322
+ """Discord button view for yes/no questions."""
323
+
324
+ def __init__(self, question: str, user_id: str, timeout: float = 300):
325
+ super().__init__(timeout=timeout)
326
+ self.value: Optional[str] = None
327
+ self.question = question
328
+ self._user_id = user_id
329
+
330
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
331
+ if str(interaction.user.id) != self._user_id:
332
+ await interaction.response.send_message("This question isn't for you.", ephemeral=True)
333
+ return False
334
+ return True
335
+
336
+ @discord.ui.button(label="Yes", style=discord.ButtonStyle.green)
337
+ async def yes_button(self, interaction: discord.Interaction, button: discord.ui.Button):
338
+ self.value = "yes"
339
+ await interaction.response.edit_message(content=f"**Q:** {self.question}\n**A:** Yes", view=None)
340
+ self.stop()
341
+
342
+ @discord.ui.button(label="No", style=discord.ButtonStyle.red)
343
+ async def no_button(self, interaction: discord.Interaction, button: discord.ui.Button):
344
+ self.value = "no"
345
+ await interaction.response.edit_message(content=f"**Q:** {self.question}\n**A:** No", view=None)
346
+ self.stop()
347
+
348
+
349
+ class _ChoiceView(discord.ui.View):
350
+ """Discord button/select view for choice questions."""
351
+
352
+ def __init__(self, question: str, options: list[str], user_id: str, timeout: float = 300):
353
+ super().__init__(timeout=timeout)
354
+ self.value: Optional[str] = None
355
+ self.question = question
356
+ self._user_id = user_id
357
+
358
+ if len(options) <= 5:
359
+ for opt in options:
360
+ self.add_item(_ChoiceButton(opt, self))
361
+ else:
362
+ self.add_item(_ChoiceSelect(options, self))
363
+
364
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
365
+ if str(interaction.user.id) != self._user_id:
366
+ await interaction.response.send_message("This question isn't for you.", ephemeral=True)
367
+ return False
368
+ return True
369
+
370
+
371
+ class _ChoiceButton(discord.ui.Button):
372
+ def __init__(self, label: str, parent_view: _ChoiceView):
373
+ super().__init__(label=label, style=discord.ButtonStyle.primary)
374
+ self._parent_view = parent_view
375
+
376
+ async def callback(self, interaction: discord.Interaction):
377
+ self._parent_view.value = self.label
378
+ await interaction.response.edit_message(
379
+ content=f"**Q:** {self._parent_view.question}\n**A:** {self.label}", view=None
380
+ )
381
+ self._parent_view.stop()
382
+
383
+
384
+ class _ChoiceSelect(discord.ui.Select):
385
+ def __init__(self, options: list[str], parent_view: _ChoiceView):
386
+ super().__init__(
387
+ placeholder="Select an option...",
388
+ options=[discord.SelectOption(label=opt) for opt in options[:25]],
389
+ )
390
+ self._parent_view = parent_view
391
+
392
+ async def callback(self, interaction: discord.Interaction):
393
+ self._parent_view.value = self.values[0]
394
+ await interaction.response.edit_message(
395
+ content=f"**Q:** {self._parent_view.question}\n**A:** {self.values[0]}", view=None
396
+ )
397
+ self._parent_view.stop()
398
+
399
+
400
+ class _TextInputModal(discord.ui.Modal):
401
+ """Discord modal for freeform text input."""
402
+
403
+ def __init__(self, question: str, timeout: float = 300):
404
+ super().__init__(title="Question", timeout=timeout)
405
+ self.value: Optional[str] = None
406
+ self._question = question
407
+ self.answer = discord.ui.TextInput(
408
+ label=question[:45], # Discord label limit is 45 chars
409
+ placeholder="Type your answer...",
410
+ style=discord.TextStyle.paragraph,
411
+ required=True,
412
+ )
413
+ self.add_item(self.answer)
414
+
415
+ async def on_submit(self, interaction: discord.Interaction):
416
+ self.value = self.answer.value
417
+ await interaction.response.defer()
418
+ self.stop()
419
+
420
+
421
+ class _TextInputView(discord.ui.View):
422
+ """View with a Reply button that opens a text input modal."""
423
+
424
+ def __init__(self, question: str, user_id: str, timeout: float = 300):
425
+ super().__init__(timeout=timeout)
426
+ self.question = question
427
+ self._user_id = user_id
428
+ self._timeout = timeout
429
+ self.modal: Optional[_TextInputModal] = None
430
+
431
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
432
+ if str(interaction.user.id) != self._user_id:
433
+ await interaction.response.send_message("This question isn't for you.", ephemeral=True)
434
+ return False
435
+ return True
436
+
437
+ @discord.ui.button(label="Reply", style=discord.ButtonStyle.primary, emoji="\u270f\ufe0f")
438
+ async def reply_button(self, interaction: discord.Interaction, button: discord.ui.Button):
439
+ self.modal = _TextInputModal(self.question, timeout=self._timeout)
440
+ await interaction.response.send_modal(self.modal)
441
+ # Wait for the modal to complete
442
+ timed_out = await self.modal.wait()
443
+ if not timed_out and self.modal.value is not None:
444
+ self.stop()
445
+
446
+
447
+ class DiscordInteractionBackend:
448
+ """Interaction backend for Discord — uses views/modals for questions."""
449
+
450
+ TIMEOUT = 300
451
+
452
+ def __init__(
453
+ self, bot: commands.Bot, channel: discord.abc.Messageable, user_id: str, loop: asyncio.AbstractEventLoop
454
+ ):
455
+ self._bot = bot
456
+ self._channel = channel
457
+ self._user_id = user_id
458
+ self._loop = loop
459
+
460
+ def ask_user(self, question: str, question_type: str = "text", options: Optional[list[str]] = None) -> str:
461
+ future = asyncio.run_coroutine_threadsafe(self._ask_async(question, question_type, options), self._loop)
462
+ try:
463
+ return future.result(timeout=self.TIMEOUT)
464
+ except asyncio.CancelledError:
465
+ raise RuntimeError("Interaction cancelled (Discord bot shutting down)")
466
+ except TimeoutError:
467
+ raise RuntimeError("Timed out waiting for user response (Discord)")
468
+
469
+ async def _ask_async(self, question: str, question_type: str, options: Optional[list[str]]) -> str:
470
+ if question_type == "yes_no":
471
+ view = _YesNoView(question, user_id=self._user_id, timeout=self.TIMEOUT)
472
+ await self._channel.send(f"**Question:** {question}", view=view)
473
+ timed_out = await view.wait()
474
+ if timed_out or view.value is None:
475
+ raise RuntimeError("Timed out waiting for user response (Discord)")
476
+ return view.value
477
+
478
+ elif question_type == "choice" and options:
479
+ view = _ChoiceView(question, options, user_id=self._user_id, timeout=self.TIMEOUT)
480
+ await self._channel.send(f"**Question:** {question}", view=view)
481
+ timed_out = await view.wait()
482
+ if timed_out or view.value is None:
483
+ raise RuntimeError("Timed out waiting for user response (Discord)")
484
+ return view.value
485
+
486
+ else: # text — use modal via button
487
+ view = _TextInputView(question, user_id=self._user_id, timeout=self.TIMEOUT)
488
+ msg = await self._channel.send(f"**Question:** {question}", view=view)
489
+ timed_out = await view.wait()
490
+ value = view.modal.value if view.modal else None
491
+ if timed_out or value is None:
492
+ raise RuntimeError("Timed out waiting for user response (Discord)")
493
+ await msg.edit(content=f"**Q:** {question}\n**A:** {value}", view=None)
494
+ return value
495
+
496
+
497
+ class DiscordAdapter(BaseAdapter):
498
+ """Discord bot adapter tied to a specific agent."""
499
+
500
+ def __init__(
501
+ self,
502
+ bot_config: DiscordBotConfig,
503
+ agent_name: str,
504
+ agent_config: AgentConfig,
505
+ session_store: "SessionStore",
506
+ identity_map: dict[str, str] | None = None,
507
+ ):
508
+ super().__init__(agent_name, agent_config, session_store, identity_map=identity_map)
509
+ self.bot_config = bot_config
510
+ self.active_progress_handlers: list[DiscordProgressHandler] = []
511
+
512
+ intents = discord.Intents.default()
513
+ intents.message_content = True
514
+
515
+ self.bot = commands.Bot(command_prefix=bot_config.command_prefix, intents=intents)
516
+ self._register_commands()
517
+
518
+ @self.bot.event
519
+ async def on_ready():
520
+ try:
521
+ if bot_config.guild_id:
522
+ guild = discord.Object(id=int(bot_config.guild_id))
523
+ self.bot.tree.copy_global_to(guild=guild)
524
+ await self.bot.tree.sync(guild=guild)
525
+ else:
526
+ await self.bot.tree.sync()
527
+ synced = len(self.bot.tree.get_commands())
528
+ logger.info(
529
+ "Discord bot '%s' logged in as %s (agent: %s, %d app commands synced)",
530
+ bot_config.name,
531
+ self.bot.user,
532
+ agent_name,
533
+ synced,
534
+ )
535
+ except Exception as e:
536
+ logger.error("Failed to sync app commands for '%s': %s", bot_config.name, e)
537
+ logger.info("Discord bot '%s' logged in as %s (agent: %s)", bot_config.name, self.bot.user, agent_name)
538
+
539
+ @self.bot.event
540
+ async def on_error(event_method, *args, **kwargs):
541
+ logger.error("[%s] Discord event error in %s", bot_config.name, event_method, exc_info=True)
542
+
543
+ @self.bot.event
544
+ async def on_message(message):
545
+ if message.author == self.bot.user:
546
+ return
547
+
548
+ is_dm = isinstance(message.channel, discord.DMChannel)
549
+ is_thread = isinstance(message.channel, discord.Thread)
550
+
551
+ # Check allowlist for both DMs and server channels
552
+ if bot_config.dm_policy == "allowlist":
553
+ if str(message.author.id) not in bot_config.allow_from:
554
+ if is_dm:
555
+ await message.channel.send("You are not authorized.")
556
+ return
557
+
558
+ def strip_mention(text: str) -> str:
559
+ return text.replace(f"<@{self.bot.user.id}>", "").replace(f"<@!{self.bot.user.id}>", "").strip()
560
+
561
+ if is_dm:
562
+ user_msg = message.content.strip()
563
+ elif is_thread:
564
+ bot_owns_thread = message.channel.owner_id == self.bot.user.id
565
+ if bot_owns_thread:
566
+ user_msg = message.content.strip()
567
+ elif self.bot.user.mentioned_in(message):
568
+ user_msg = strip_mention(message.content)
569
+ else:
570
+ return
571
+ else:
572
+ if not self.bot.user.mentioned_in(message):
573
+ return
574
+ user_msg = strip_mention(message.content)
575
+
576
+ if not user_msg:
577
+ return
578
+
579
+ channel_type = "thread" if is_thread else ("DM" if is_dm else "channel")
580
+ logger.info("[%s] <- %s (%s): %s", bot_config.name, message.author, channel_type, user_msg[:100])
581
+
582
+ task = asyncio.create_task(self._process_message(message, user_msg, bot_config.name))
583
+ task.add_done_callback(lambda t: _handle_async_exception(t, bot_config.name))
584
+
585
+ def _register_commands(self):
586
+ """Auto-register adapter commands from the shared registry as Discord app commands."""
587
+ from tsugite_daemon.commands import get_commands
588
+
589
+ for cmd in get_commands().values():
590
+ self._add_app_command(cmd)
591
+
592
+ def _add_app_command(self, cmd: "AdapterCommand"):
593
+ """Convert an AdapterCommand to a discord app_commands.Command and add to the bot tree."""
594
+ adapter = self
595
+
596
+ # user_id is auto-injected from the interaction, so hide it from the Discord UI
597
+ visible_params = [p for p in cmd.params if p.name != "user_id"]
598
+ auto_inject_user_id = len(visible_params) < len(cmd.params)
599
+
600
+ sig_params = [
601
+ inspect.Parameter("interaction", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=discord.Interaction)
602
+ ]
603
+ for p in visible_params:
604
+ ann = Optional[p.type] if not p.required else p.type
605
+ default = inspect.Parameter.empty if p.required else None
606
+ sig_params.append(
607
+ inspect.Parameter(p.name, inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=ann, default=default)
608
+ )
609
+
610
+ async def callback(interaction: discord.Interaction, **kwargs):
611
+ await interaction.response.defer()
612
+ if auto_inject_user_id:
613
+ kwargs.setdefault("user_id", str(interaction.user.id))
614
+ try:
615
+ result = await cmd.handler(adapter, **kwargs)
616
+ except Exception as e:
617
+ logger.error("App command '%s' failed: %s", cmd.name, e)
618
+ result = f"Command failed: {e}"
619
+ await interaction.followup.send(str(result)[:2000])
620
+
621
+ callback.__signature__ = inspect.Signature(sig_params)
622
+ callback.__annotations__ = {p.name: p.annotation for p in sig_params}
623
+
624
+ descriptions = {p.name: p.description for p in visible_params}
625
+ if descriptions:
626
+ callback = discord.app_commands.describe(**descriptions)(callback)
627
+
628
+ app_cmd = discord.app_commands.Command(name=cmd.name, description=cmd.description, callback=callback)
629
+ self.bot.tree.add_command(app_cmd)
630
+
631
+ def _resolve_target_session(
632
+ self, message, channel_context: ChannelContext, is_thread: bool, is_dm: bool, thread_id: Optional[str]
633
+ ) -> Session:
634
+ """Pick which session this message routes to.
635
+
636
+ Channels and threads stay shared across users (team-agent context). DMs are
637
+ per-user, and when bot_config.session_name is set DMs route to the named session
638
+ for that user instead of their default-interactive.
639
+ """
640
+ user_id = self.resolve_user(str(message.author.id), channel_context)
641
+
642
+ if is_thread and thread_id:
643
+ existing = self.session_store.find_by_thread(thread_id)
644
+ if existing:
645
+ return existing
646
+ parent_session = self.session_store.get_or_create_interactive(user_id, self.agent_name)
647
+ parent_channel_id = getattr(message.channel, "parent_id", None)
648
+ thread_session = Session(
649
+ id="",
650
+ agent=self.agent_name,
651
+ source=SessionSource.INTERACTIVE.value,
652
+ user_id=user_id,
653
+ parent_id=parent_session.id,
654
+ metadata={
655
+ "thread_name": getattr(message.channel, "name", ""),
656
+ "channel_id": str(parent_channel_id) if parent_channel_id else None,
657
+ "thread_id": thread_id,
658
+ },
659
+ )
660
+ self.session_store.create_session(thread_session)
661
+ return thread_session
662
+
663
+ if not is_dm and message.guild:
664
+ return self.session_store.get_or_create_channel_session(
665
+ channel_id=str(message.channel.id),
666
+ agent=self.agent_name,
667
+ user_id=user_id,
668
+ )
669
+
670
+ if self.bot_config.session_name:
671
+ return self.session_store.get_or_create_named_session(
672
+ user_id, self.agent_name, self.bot_config.session_name
673
+ )
674
+
675
+ return self.session_store.get_or_create_interactive(user_id, self.agent_name)
676
+
677
+ @staticmethod
678
+ def _channel_display_name(channel) -> str:
679
+ return getattr(channel, "name", "?")
680
+
681
+ def _build_progress_header(self, message, session: Session, is_thread: bool, is_dm: bool) -> str:
682
+ if is_thread:
683
+ route = f"thread {self._channel_display_name(message.channel)}"
684
+ elif not is_dm and message.guild:
685
+ route = f"#{self._channel_display_name(message.channel)}"
686
+ elif self.bot_config.session_name:
687
+ route = f"DM · named: {self.bot_config.session_name}"
688
+ else:
689
+ route = "DM"
690
+
691
+ identifier = session.metadata.get("topic") or session.id[:6]
692
+ return f"🤔 {route} · {identifier}"
693
+
694
+ async def _process_message(self, message, user_msg: str, bot_name: str):
695
+ """Process a message in an isolated task."""
696
+ is_thread = isinstance(message.channel, discord.Thread)
697
+ is_dm = isinstance(message.channel, discord.DMChannel)
698
+ thread_id = str(message.channel.id) if is_thread else None
699
+
700
+ channel_context = ChannelContext(
701
+ source="discord",
702
+ channel_id=str(message.channel.id),
703
+ user_id=str(message.author.id),
704
+ reply_to=f"discord:{message.channel.id}",
705
+ thread_id=thread_id,
706
+ metadata={
707
+ "author_name": str(message.author),
708
+ "guild_id": str(message.guild.id) if message.guild else None,
709
+ },
710
+ )
711
+
712
+ target_session = self._resolve_target_session(message, channel_context, is_thread, is_dm, thread_id)
713
+ channel_context.metadata["conv_id_override"] = target_session.id
714
+
715
+ header_text = self._build_progress_header(message, target_session, is_thread, is_dm)
716
+
717
+ loop = asyncio.get_running_loop()
718
+ progress = DiscordProgressHandler(message.channel, loop, trigger_message=message, header_text=header_text)
719
+ sse_handler = SSEBroadcastHandler(
720
+ broadcaster=self.event_bus,
721
+ session_id=target_session.id,
722
+ persist_event=lambda payload: self.session_store.append_event(
723
+ target_session.id,
724
+ {**payload, "timestamp": datetime.now(timezone.utc).isoformat()},
725
+ ),
726
+ )
727
+ custom_logger = SimpleNamespace(ui_handler=CompositeUIHandler(progress, sse_handler))
728
+
729
+ from tsugite.interaction import set_interaction_backend
730
+
731
+ interaction_backend = DiscordInteractionBackend(
732
+ bot=self.bot,
733
+ channel=message.channel,
734
+ user_id=str(message.author.id),
735
+ loop=loop,
736
+ )
737
+ set_interaction_backend(interaction_backend)
738
+
739
+ await progress.start_typing_loop()
740
+ self.active_progress_handlers.append(progress)
741
+
742
+ try:
743
+ response = await self.handle_message(
744
+ user_id=str(message.author.id),
745
+ message=user_msg,
746
+ channel_context=channel_context,
747
+ custom_logger=custom_logger,
748
+ )
749
+ await progress.cleanup(success=True)
750
+ if not sse_handler.has_final:
751
+ sse_handler._emit("final_result", {"result": response})
752
+
753
+ except Exception as e:
754
+ await progress.cleanup(success=False)
755
+ response = f"Error processing message: {e}"
756
+ logger.error("[%s] %s", bot_name, e, exc_info=True)
757
+ if not sse_handler.has_final:
758
+ sse_handler._emit("error", {"error": str(e)})
759
+ finally:
760
+ # Remove from active handlers after cleanup
761
+ if progress in self.active_progress_handlers:
762
+ self.active_progress_handlers.remove(progress)
763
+
764
+ logger.info("[%s] -> %s: %s", bot_name, message.author, response[:100])
765
+ await self._send_chunked(message.channel, response)
766
+
767
+ def _split_respecting_code_blocks(self, text: str, limit: int) -> list[str]:
768
+ """Split text into chunks respecting code block boundaries.
769
+
770
+ Args:
771
+ text: Text to split
772
+ limit: Maximum chunk size
773
+
774
+ Returns:
775
+ List of chunks
776
+ """
777
+ code_block_pattern = re.compile(r"```(\w*)\n([\s\S]*?)```")
778
+ closing_fence_len = 4 # "\n```"
779
+
780
+ chunks: list[str] = []
781
+ current = ""
782
+
783
+ def flush_current() -> None:
784
+ nonlocal current
785
+ if current.strip():
786
+ chunks.append(current.rstrip("\n"))
787
+ current = ""
788
+
789
+ def add_text_lines(text_content: str) -> None:
790
+ nonlocal current
791
+ for line in text_content.split("\n"):
792
+ if len(current) + len(line) + 1 <= limit:
793
+ current += line + "\n"
794
+ else:
795
+ flush_current()
796
+ current = line + "\n"
797
+
798
+ def add_code_block(full_block: str, lang: str, inner: str) -> None:
799
+ nonlocal current
800
+
801
+ if len(current) + len(full_block) <= limit:
802
+ current += full_block
803
+ return
804
+
805
+ if len(full_block) <= limit:
806
+ flush_current()
807
+ current = full_block
808
+ return
809
+
810
+ flush_current()
811
+ header = f"```{lang}\n"
812
+ code_chunk = header
813
+
814
+ for line in inner.split("\n"):
815
+ line_with_newline = line + "\n"
816
+ if len(code_chunk) + len(line_with_newline) + closing_fence_len <= limit:
817
+ code_chunk += line_with_newline
818
+ else:
819
+ chunks.append(code_chunk.rstrip("\n") + "\n```")
820
+ code_chunk = header + line_with_newline
821
+
822
+ if code_chunk != header:
823
+ current = code_chunk.rstrip("\n") + "\n```"
824
+
825
+ last_end = 0
826
+ for match in code_block_pattern.finditer(text):
827
+ if match.start() > last_end:
828
+ add_text_lines(text[last_end : match.start()])
829
+
830
+ lang = match.group(1)
831
+ inner = match.group(2).strip("\n")
832
+ full_block = f"```{lang}\n{inner}\n```"
833
+ add_code_block(full_block, lang, inner)
834
+ last_end = match.end()
835
+
836
+ if last_end < len(text):
837
+ add_text_lines(text[last_end:])
838
+
839
+ flush_current()
840
+ return chunks
841
+
842
+ async def _send_chunked(self, channel, text: str):
843
+ """Send message, splitting if longer than 2000 chars while respecting code blocks.
844
+
845
+ Args:
846
+ channel: Discord channel to send to
847
+ text: Message text to send
848
+ """
849
+ limit = 2000
850
+
851
+ if len(text) <= limit:
852
+ await channel.send(text)
853
+ return
854
+
855
+ chunks = self._split_respecting_code_blocks(text, limit)
856
+ for chunk in chunks:
857
+ if chunk.strip():
858
+ await channel.send(chunk)
859
+
860
+ # ── ThreadCapability implementation ──
861
+
862
+ async def _get_thread(self, platform_thread_id: str) -> discord.Thread:
863
+ """Fetch a Discord thread by ID, raising if not found or not a thread."""
864
+ channel = self.bot.get_channel(int(platform_thread_id))
865
+ if not channel:
866
+ channel = await self.bot.fetch_channel(int(platform_thread_id))
867
+ if not isinstance(channel, discord.Thread):
868
+ raise RuntimeError(f"Channel {platform_thread_id} is not a thread")
869
+ return channel
870
+
871
+ async def create_thread(self, channel_id: str, title: str) -> str:
872
+ """Create a Discord thread in the specified channel."""
873
+ channel = self.bot.get_channel(int(channel_id))
874
+ if not channel:
875
+ channel = await self.bot.fetch_channel(int(channel_id))
876
+ thread = await channel.create_thread(name=title[:100], type=discord.ChannelType.public_thread)
877
+ return str(thread.id)
878
+
879
+ async def send_to_thread(self, platform_thread_id: str, message: str) -> None:
880
+ thread = await self._get_thread(platform_thread_id)
881
+ if thread.archived:
882
+ await thread.edit(archived=False)
883
+ await self._send_chunked(thread, message)
884
+
885
+ async def close_thread(self, platform_thread_id: str) -> None:
886
+ thread = await self._get_thread(platform_thread_id)
887
+ await thread.edit(archived=True)
888
+
889
+ async def start(self):
890
+ """Start Discord bot with retry on transient errors."""
891
+ max_retries = 5
892
+ backoff = 5
893
+ for attempt in range(max_retries):
894
+ try:
895
+ await self.bot.start(self.bot_config.resolve_token())
896
+ break
897
+ except (discord.errors.DiscordServerError, OSError) as e:
898
+ if attempt < max_retries - 1:
899
+ delay = backoff * (2**attempt)
900
+ logger.warning(
901
+ "Discord connection failed (attempt %d/%d): %s — retrying in %ds",
902
+ attempt + 1,
903
+ max_retries,
904
+ e,
905
+ delay,
906
+ )
907
+ await asyncio.sleep(delay)
908
+ else:
909
+ logger.error("Discord adapter failed after %d retries", max_retries)
910
+ raise
911
+
912
+ async def stop(self):
913
+ """Stop Discord bot and clean up resources."""
914
+ # Clean up any active progress handlers (orphaned typing tasks)
915
+ for progress in self.active_progress_handlers[:]: # Copy list to avoid modification during iteration
916
+ await progress.cleanup(success=False)
917
+ self.active_progress_handlers.clear()
918
+
919
+ await self.bot.close()