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,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()
|