linkgravity 1.2.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +160 -80
- package/bin/platforms.js +63 -0
- package/bin/setup.js +172 -76
- package/hooks/hook.py +3 -4
- package/package.json +1 -1
- package/requirements.txt +1 -0
- package/src/api/server.py +2 -4
- package/src/api/ui_routes.py +59 -18
- package/src/api/voice_routes.py +5 -8
- package/src/cogs/general_cog.py +4 -1
- package/src/cogs/voice/enrollment.py +1 -1
- package/src/cogs/voice_cog.py +28 -23
- package/src/config.py +9 -4
- package/src/core/agy_runner.py +36 -3
- package/src/core/session_manager.py +25 -0
- package/src/handlers/message_router.py +6 -9
- package/src/handlers/thread_reply.py +63 -33
- package/src/main.py +40 -278
- package/src/main_discord.py +311 -0
- package/src/main_telegram.py +202 -0
- package/src/messengers/base.py +46 -7
- package/src/messengers/discord_adapter.py +72 -5
- package/src/messengers/registry.py +28 -12
- package/src/messengers/telegram_adapter.py +372 -0
- package/src/services/audio_service.py +2 -2
- package/src/services/discord_helpers.py +3 -4
- package/src/services/response.py +2 -2
- package/src/services/streaming.py +24 -16
- package/src/utils/utils.py +2 -0
- package/voice-service/index.js +25 -22
- package/src/services/discord_mcp.py +0 -50
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Telegram implementation of MessengerAdapter. 1 chat = 1 session (no
|
|
2
|
+
forum-topic support yet) - see handoff notes for the forum-mode follow-up."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from contextlib import asynccontextmanager, suppress
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from telegram import ForceReply, InlineKeyboardButton, InlineKeyboardMarkup, Message, ReactionTypeEmoji, Update
|
|
13
|
+
from telegram.constants import ChatAction
|
|
14
|
+
from telegram.error import TelegramError
|
|
15
|
+
from telegram.ext import ContextTypes
|
|
16
|
+
|
|
17
|
+
from config import logger
|
|
18
|
+
from messengers.base import (
|
|
19
|
+
IncomingAttachment,
|
|
20
|
+
IncomingMessage,
|
|
21
|
+
MessengerAdapter,
|
|
22
|
+
PromptHandle,
|
|
23
|
+
ScopeOption,
|
|
24
|
+
ToolApprovalOutcome,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_CODE_BLOCK_RE = re.compile(r"```(?:\w+\n)?(.*?)```|`([^`\n]+)`", re.DOTALL)
|
|
28
|
+
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def markdown_to_telegram_html(text: str) -> str:
|
|
32
|
+
"""Converts the Discord-flavored markdown this codebase generates (bold, code fences) into Telegram's HTML parse mode, escaping everything else."""
|
|
33
|
+
out = []
|
|
34
|
+
pos = 0
|
|
35
|
+
for m in _CODE_BLOCK_RE.finditer(text):
|
|
36
|
+
out.append(_BOLD_RE.sub(r"<b>\1</b>", html.escape(text[pos : m.start()])))
|
|
37
|
+
content = m.group(1) if m.group(1) is not None else m.group(2)
|
|
38
|
+
tag = "pre" if m.group(1) is not None else "code"
|
|
39
|
+
out.append(f"<{tag}>{html.escape(content)}</{tag}>")
|
|
40
|
+
pos = m.end()
|
|
41
|
+
out.append(_BOLD_RE.sub(r"<b>\1</b>", html.escape(text[pos:])))
|
|
42
|
+
return "".join(out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _safe_query_edit(query, **kwargs) -> None:
|
|
46
|
+
try:
|
|
47
|
+
await query.edit_message_text(**kwargs)
|
|
48
|
+
except TelegramError as e:
|
|
49
|
+
if "message is not modified" in str(e).lower():
|
|
50
|
+
return
|
|
51
|
+
logger.error(f"Failed to edit Telegram message via callback query: {e}")
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _TelegramPromptHandle(PromptHandle):
|
|
56
|
+
def __init__(self, bot, text: str, reply_markup, cleanup: Callable[[], None] | None = None):
|
|
57
|
+
self.bot = bot
|
|
58
|
+
self.text = text
|
|
59
|
+
self.reply_markup = reply_markup
|
|
60
|
+
self._cleanup = cleanup
|
|
61
|
+
self.chat_id: int | None = None
|
|
62
|
+
self.message_id: int | None = None
|
|
63
|
+
self.outcome: ToolApprovalOutcome | None = None
|
|
64
|
+
|
|
65
|
+
async def send(self, conversation_ref: int) -> Message:
|
|
66
|
+
text = self.text if len(self.text) <= 4000 else self.text[:3997] + "..."
|
|
67
|
+
try:
|
|
68
|
+
msg = await self.bot.send_message(
|
|
69
|
+
chat_id=conversation_ref, text=text, reply_markup=self.reply_markup, parse_mode="HTML"
|
|
70
|
+
)
|
|
71
|
+
except TelegramError as e:
|
|
72
|
+
logger.error(f"Failed to send Telegram prompt message: {e}")
|
|
73
|
+
raise
|
|
74
|
+
self.chat_id = msg.chat_id
|
|
75
|
+
self.message_id = msg.message_id
|
|
76
|
+
return msg
|
|
77
|
+
|
|
78
|
+
async def finalize(self) -> None:
|
|
79
|
+
if self._cleanup:
|
|
80
|
+
self._cleanup()
|
|
81
|
+
if self.message_id is None:
|
|
82
|
+
return
|
|
83
|
+
try:
|
|
84
|
+
await self.bot.edit_message_text(
|
|
85
|
+
chat_id=self.chat_id, message_id=self.message_id, text=self.text, reply_markup=None, parse_mode="HTML"
|
|
86
|
+
)
|
|
87
|
+
except TelegramError as e:
|
|
88
|
+
logger.warning(f"Failed to finalize Telegram prompt message: {e}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TelegramAdapter(MessengerAdapter):
|
|
92
|
+
platform_name = "telegram"
|
|
93
|
+
supports_renaming = False
|
|
94
|
+
|
|
95
|
+
def __init__(self, bot):
|
|
96
|
+
self.bot = bot
|
|
97
|
+
# callback_data -> async handler(query); prompts add/remove their own keys here.
|
|
98
|
+
self._callbacks: dict[str, Callable[[Any], Awaitable[None]]] = {}
|
|
99
|
+
|
|
100
|
+
def register_callback(self, key: str, handler: Callable[[Any], Awaitable[None]]) -> None:
|
|
101
|
+
self._callbacks[key] = handler
|
|
102
|
+
|
|
103
|
+
async def handle_callback_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
104
|
+
query = update.callback_query
|
|
105
|
+
if query is None or query.data is None:
|
|
106
|
+
return
|
|
107
|
+
handler = self._callbacks.pop(query.data, None)
|
|
108
|
+
if handler is None:
|
|
109
|
+
await query.answer("This action has expired.", show_alert=True)
|
|
110
|
+
return
|
|
111
|
+
await handler(query)
|
|
112
|
+
|
|
113
|
+
# -- inbound -------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _make_reader(self, file_id: str) -> Callable[[], Awaitable[bytes]]:
|
|
116
|
+
async def _reader() -> bytes:
|
|
117
|
+
file = await self.bot.get_file(file_id)
|
|
118
|
+
data = await file.download_as_bytearray()
|
|
119
|
+
return bytes(data)
|
|
120
|
+
|
|
121
|
+
return _reader
|
|
122
|
+
|
|
123
|
+
def to_incoming_message(self, raw_event: Update) -> IncomingMessage | None:
|
|
124
|
+
update = raw_event
|
|
125
|
+
message = update.message
|
|
126
|
+
if message is None or message.from_user is None or message.from_user.is_bot:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
attachments = []
|
|
130
|
+
if message.photo:
|
|
131
|
+
largest = message.photo[-1]
|
|
132
|
+
attachments.append(
|
|
133
|
+
IncomingAttachment(
|
|
134
|
+
filename="photo.jpg", content_type="image/jpeg", reader=self._make_reader(largest.file_id)
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
if message.document:
|
|
138
|
+
attachments.append(
|
|
139
|
+
IncomingAttachment(
|
|
140
|
+
filename=message.document.file_name or "file",
|
|
141
|
+
content_type=message.document.mime_type,
|
|
142
|
+
reader=self._make_reader(message.document.file_id),
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
if message.voice:
|
|
146
|
+
attachments.append(
|
|
147
|
+
IncomingAttachment(
|
|
148
|
+
filename="voice.ogg",
|
|
149
|
+
content_type=message.voice.mime_type or "audio/ogg",
|
|
150
|
+
reader=self._make_reader(message.voice.file_id),
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
if message.audio:
|
|
154
|
+
attachments.append(
|
|
155
|
+
IncomingAttachment(
|
|
156
|
+
filename=message.audio.file_name or "audio",
|
|
157
|
+
content_type=message.audio.mime_type,
|
|
158
|
+
reader=self._make_reader(message.audio.file_id),
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def add_reaction(emoji: str) -> None:
|
|
163
|
+
try:
|
|
164
|
+
await self.bot.set_message_reaction(
|
|
165
|
+
chat_id=message.chat_id, message_id=message.message_id, reaction=[ReactionTypeEmoji(emoji=emoji)]
|
|
166
|
+
)
|
|
167
|
+
except TelegramError as e:
|
|
168
|
+
logger.warning(f"Failed to set Telegram reaction: {e}")
|
|
169
|
+
|
|
170
|
+
return IncomingMessage(
|
|
171
|
+
author_id=message.from_user.id,
|
|
172
|
+
platform=self.platform_name,
|
|
173
|
+
content=message.text or message.caption or "",
|
|
174
|
+
conversation_id=str(message.chat_id),
|
|
175
|
+
conversation_ref=message.chat_id,
|
|
176
|
+
attachments=attachments,
|
|
177
|
+
add_reaction=add_reaction,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# -- plain messaging ---------------------------------------------------
|
|
181
|
+
|
|
182
|
+
async def send_message(self, conversation_ref: int, text: str) -> Message:
|
|
183
|
+
try:
|
|
184
|
+
return await self.bot.send_message(
|
|
185
|
+
chat_id=conversation_ref, text=markdown_to_telegram_html(text), parse_mode="HTML"
|
|
186
|
+
)
|
|
187
|
+
except TelegramError as e:
|
|
188
|
+
logger.error(f"Failed to send Telegram message: {e}")
|
|
189
|
+
raise
|
|
190
|
+
|
|
191
|
+
async def edit_message(self, message_ref: Message, text: str) -> bool:
|
|
192
|
+
try:
|
|
193
|
+
await self.bot.edit_message_text(chat_id=message_ref.chat_id, message_id=message_ref.message_id, text=text)
|
|
194
|
+
return True
|
|
195
|
+
except TelegramError as e:
|
|
196
|
+
if "message is not modified" in str(e).lower():
|
|
197
|
+
return True # already showing this exact text - not a real failure, don't send a duplicate
|
|
198
|
+
logger.warning(f"Failed to edit Telegram message: {e}")
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
async def send_files(self, conversation_ref: int, file_paths: list[str]) -> None:
|
|
202
|
+
for path in file_paths:
|
|
203
|
+
with open(path, "rb") as f:
|
|
204
|
+
await self.bot.send_document(chat_id=conversation_ref, document=f)
|
|
205
|
+
|
|
206
|
+
def resolve_conversation(self, conversation_id: str) -> Any:
|
|
207
|
+
try:
|
|
208
|
+
return int(conversation_id)
|
|
209
|
+
except (TypeError, ValueError):
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
async def start_conversation(self, origin_ref: int, title: str) -> int:
|
|
213
|
+
# Non-forum mode: the chat itself is the session, nothing to create.
|
|
214
|
+
return origin_ref
|
|
215
|
+
|
|
216
|
+
async def rename_conversation(self, conversation_ref: int, title: str) -> None:
|
|
217
|
+
pass # No per-session title surface outside forum-topic mode.
|
|
218
|
+
|
|
219
|
+
@asynccontextmanager
|
|
220
|
+
async def typing(self, conversation_ref: int):
|
|
221
|
+
async def _keep_typing():
|
|
222
|
+
while True:
|
|
223
|
+
with suppress(TelegramError):
|
|
224
|
+
await self.bot.send_chat_action(chat_id=conversation_ref, action=ChatAction.TYPING)
|
|
225
|
+
await asyncio.sleep(4)
|
|
226
|
+
|
|
227
|
+
task = asyncio.create_task(_keep_typing())
|
|
228
|
+
try:
|
|
229
|
+
yield
|
|
230
|
+
finally:
|
|
231
|
+
task.cancel()
|
|
232
|
+
with suppress(asyncio.CancelledError):
|
|
233
|
+
await task
|
|
234
|
+
|
|
235
|
+
# -- interactive prompts ----------------------------------------------
|
|
236
|
+
|
|
237
|
+
def create_tool_approval_prompt(
|
|
238
|
+
self,
|
|
239
|
+
decision_future: asyncio.Future,
|
|
240
|
+
title: str,
|
|
241
|
+
body: str,
|
|
242
|
+
scope_options: list[ScopeOption],
|
|
243
|
+
) -> PromptHandle:
|
|
244
|
+
prompt_id = uuid.uuid4().hex[:12]
|
|
245
|
+
text = f"<b>{html.escape(title)}</b>\n\n{markdown_to_telegram_html(body)}"
|
|
246
|
+
keys: list[str] = []
|
|
247
|
+
keyboard: list[list[InlineKeyboardButton]] = []
|
|
248
|
+
|
|
249
|
+
async def resolve(decision: str, scope: ScopeOption | None, query):
|
|
250
|
+
handle.outcome = ToolApprovalOutcome(decision=decision, scope=scope)
|
|
251
|
+
if not decision_future.done():
|
|
252
|
+
decision_future.set_result(decision)
|
|
253
|
+
if decision == "allow" and scope:
|
|
254
|
+
new_text = f"✅ <b>Approved & auto-allowed ({html.escape(scope.scope)})</b>"
|
|
255
|
+
elif decision == "allow":
|
|
256
|
+
new_text = "✅ <b>Approved</b>"
|
|
257
|
+
else:
|
|
258
|
+
new_text = "❌ <b>Rejected</b>"
|
|
259
|
+
handle.text = new_text
|
|
260
|
+
await query.answer()
|
|
261
|
+
await _safe_query_edit(query, text=new_text, parse_mode="HTML")
|
|
262
|
+
|
|
263
|
+
allow_key = f"{prompt_id}:allow"
|
|
264
|
+
self._callbacks[allow_key] = lambda query: resolve("allow", None, query)
|
|
265
|
+
keys.append(allow_key)
|
|
266
|
+
keyboard.append([InlineKeyboardButton("✅ Approve once", callback_data=allow_key)])
|
|
267
|
+
|
|
268
|
+
for i, opt in enumerate(scope_options):
|
|
269
|
+
suffix = " tool" if opt.kind == "tools" else ""
|
|
270
|
+
label = f"♾️ Allow [{opt.scope}]{suffix}"
|
|
271
|
+
if len(label) > 64:
|
|
272
|
+
label = label[:61] + "…"
|
|
273
|
+
key = f"{prompt_id}:scope:{i}"
|
|
274
|
+
self._callbacks[key] = lambda query, opt=opt: resolve("allow", opt, query)
|
|
275
|
+
keys.append(key)
|
|
276
|
+
keyboard.append([InlineKeyboardButton(label, callback_data=key)])
|
|
277
|
+
|
|
278
|
+
reject_key = f"{prompt_id}:reject"
|
|
279
|
+
self._callbacks[reject_key] = lambda query: resolve("reject", None, query)
|
|
280
|
+
keys.append(reject_key)
|
|
281
|
+
keyboard.append([InlineKeyboardButton("❌ Reject", callback_data=reject_key)])
|
|
282
|
+
|
|
283
|
+
handle = _TelegramPromptHandle(
|
|
284
|
+
self.bot, text, InlineKeyboardMarkup(keyboard), cleanup=lambda: [self._callbacks.pop(k, None) for k in keys]
|
|
285
|
+
)
|
|
286
|
+
return handle
|
|
287
|
+
|
|
288
|
+
def create_question_prompt(
|
|
289
|
+
self,
|
|
290
|
+
answer_future: asyncio.Future,
|
|
291
|
+
question: str,
|
|
292
|
+
options: list[str],
|
|
293
|
+
multi_select: bool = False,
|
|
294
|
+
allow_write_in: bool = True,
|
|
295
|
+
) -> PromptHandle:
|
|
296
|
+
prompt_id = uuid.uuid4().hex[:12]
|
|
297
|
+
text = f"❓ <b>Question from AI</b>\n\n<b>{html.escape(question)}</b>\n\nPlease choose an answer below."
|
|
298
|
+
keys: list[str] = []
|
|
299
|
+
|
|
300
|
+
async def resolve(chosen_text: str, note: str, query):
|
|
301
|
+
if not answer_future.done():
|
|
302
|
+
answer_future.set_result(chosen_text)
|
|
303
|
+
new_text = f"✅ <b>{note}: {html.escape(chosen_text)}</b>"
|
|
304
|
+
handle.text = new_text
|
|
305
|
+
await query.answer()
|
|
306
|
+
await _safe_query_edit(query, text=new_text, parse_mode="HTML")
|
|
307
|
+
|
|
308
|
+
async def write_in(query):
|
|
309
|
+
await query.answer()
|
|
310
|
+
await _safe_query_edit(
|
|
311
|
+
query,
|
|
312
|
+
text=f"❓ <b>{html.escape(question)}</b>\n\n💬 Reply with your answer as a message.",
|
|
313
|
+
parse_mode="HTML",
|
|
314
|
+
reply_markup=ForceReply(selective=True),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
if allow_write_in:
|
|
318
|
+
write_in_key = f"{prompt_id}:write_in"
|
|
319
|
+
self._callbacks[write_in_key] = write_in
|
|
320
|
+
keys.append(write_in_key)
|
|
321
|
+
|
|
322
|
+
if multi_select and options:
|
|
323
|
+
selected: set[int] = set()
|
|
324
|
+
shown = options[:20]
|
|
325
|
+
|
|
326
|
+
def render_keyboard() -> InlineKeyboardMarkup:
|
|
327
|
+
rows = [
|
|
328
|
+
[
|
|
329
|
+
InlineKeyboardButton(
|
|
330
|
+
("☑️ " if i in selected else "⬜ ") + opt[:60], callback_data=f"{prompt_id}:toggle:{i}"
|
|
331
|
+
)
|
|
332
|
+
]
|
|
333
|
+
for i, opt in enumerate(shown)
|
|
334
|
+
]
|
|
335
|
+
rows.append([InlineKeyboardButton("Submit", callback_data=f"{prompt_id}:submit")])
|
|
336
|
+
if allow_write_in:
|
|
337
|
+
rows.append([InlineKeyboardButton("✍️ Write in", callback_data=f"{prompt_id}:write_in")])
|
|
338
|
+
return InlineKeyboardMarkup(rows)
|
|
339
|
+
|
|
340
|
+
async def toggle(i: int, query):
|
|
341
|
+
selected.symmetric_difference_update({i})
|
|
342
|
+
await query.answer()
|
|
343
|
+
await query.edit_message_reply_markup(reply_markup=render_keyboard())
|
|
344
|
+
|
|
345
|
+
async def submit(query):
|
|
346
|
+
if not selected:
|
|
347
|
+
await query.answer("Select at least one option first.", show_alert=True)
|
|
348
|
+
return
|
|
349
|
+
chosen = ", ".join(shown[i] for i in sorted(selected))
|
|
350
|
+
await resolve(chosen, "Selected", query)
|
|
351
|
+
|
|
352
|
+
for i in range(len(shown)):
|
|
353
|
+
key = f"{prompt_id}:toggle:{i}"
|
|
354
|
+
self._callbacks[key] = lambda query, i=i: toggle(i, query)
|
|
355
|
+
keys.append(key)
|
|
356
|
+
self._callbacks[f"{prompt_id}:submit"] = submit
|
|
357
|
+
keys.append(f"{prompt_id}:submit")
|
|
358
|
+
keyboard = render_keyboard().inline_keyboard
|
|
359
|
+
else:
|
|
360
|
+
keyboard = []
|
|
361
|
+
for i, opt in enumerate(options[:20]):
|
|
362
|
+
key = f"{prompt_id}:opt:{i}"
|
|
363
|
+
self._callbacks[key] = lambda query, opt=opt: resolve(opt, "Selected", query)
|
|
364
|
+
keys.append(key)
|
|
365
|
+
keyboard.append([InlineKeyboardButton(opt[:64], callback_data=key)])
|
|
366
|
+
if allow_write_in:
|
|
367
|
+
keyboard.append([InlineKeyboardButton("✍️ Write in", callback_data=f"{prompt_id}:write_in")])
|
|
368
|
+
|
|
369
|
+
handle = _TelegramPromptHandle(
|
|
370
|
+
self.bot, text, InlineKeyboardMarkup(keyboard), cleanup=lambda: [self._callbacks.pop(k, None) for k in keys]
|
|
371
|
+
)
|
|
372
|
+
return handle
|
|
@@ -37,7 +37,7 @@ async def tts(text: str, voice: str = None) -> bytes | None:
|
|
|
37
37
|
os.unlink(tmp)
|
|
38
38
|
return data
|
|
39
39
|
except Exception as e:
|
|
40
|
-
logger.
|
|
40
|
+
logger.exception(f"TTS error: {e}")
|
|
41
41
|
return None
|
|
42
42
|
|
|
43
43
|
|
|
@@ -63,5 +63,5 @@ async def stt(audio_bytes: bytes) -> str | None:
|
|
|
63
63
|
result = await asyncio.to_thread(_transcribe)
|
|
64
64
|
return result.strip() if result else None
|
|
65
65
|
except Exception as e:
|
|
66
|
-
logger.
|
|
66
|
+
logger.exception(f"STT internal error: {e}")
|
|
67
67
|
return None
|
|
@@ -3,14 +3,13 @@ import string
|
|
|
3
3
|
import uuid
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
|
|
6
|
-
import discord
|
|
7
|
-
|
|
8
6
|
from config import TMP_FILE_DIR, logger
|
|
7
|
+
from messengers.base import IncomingAttachment
|
|
9
8
|
|
|
10
9
|
|
|
11
|
-
async def handle_image_attachments(
|
|
10
|
+
async def handle_image_attachments(attachments: list[IncomingAttachment]) -> list[str]:
|
|
12
11
|
saved_paths = []
|
|
13
|
-
for att in
|
|
12
|
+
for att in attachments:
|
|
14
13
|
ext = Path(att.filename).suffix.lower()
|
|
15
14
|
filename = f"{uuid.uuid4().hex}{ext or '.tmp'}"
|
|
16
15
|
dest = TMP_FILE_DIR / filename
|
package/src/services/response.py
CHANGED
|
@@ -2,7 +2,7 @@ from pathlib import Path
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
4
|
from config import MAX_EMBED_LEN, MODEL_CHOICES, session_manager
|
|
5
|
-
from messengers.registry import
|
|
5
|
+
from messengers.registry import get_adapter_for_platform
|
|
6
6
|
from utils.utils import get_current_model
|
|
7
7
|
|
|
8
8
|
|
|
@@ -14,7 +14,7 @@ async def send_agy_response(
|
|
|
14
14
|
start_time: float = 0,
|
|
15
15
|
conv_id: str = None,
|
|
16
16
|
):
|
|
17
|
-
adapter =
|
|
17
|
+
adapter = get_adapter_for_platform(session.get("platform", "discord"))
|
|
18
18
|
session_manager.save_sessions()
|
|
19
19
|
|
|
20
20
|
parts = [response_text[i : i + MAX_EMBED_LEN] for i in range(0, max(len(response_text), 1), MAX_EMBED_LEN)]
|
|
@@ -6,25 +6,33 @@ from typing import Any
|
|
|
6
6
|
import discord # only for voice/TTS text cleanup below; messaging goes through the adapter
|
|
7
7
|
|
|
8
8
|
from config import MAX_EMBED_LEN, STREAM_RATE_LIMIT_SEC, bot_settings, logger, session_manager
|
|
9
|
-
from messengers.registry import
|
|
9
|
+
from messengers.registry import get_adapter_for_thread
|
|
10
10
|
from utils.utils import clean_ansi
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def _clear_current_tool(thread_id: str):
|
|
14
|
-
"""Removes the "current_tool"
|
|
15
|
-
tool call is being approved - without
|
|
16
|
-
stays stuck on the last tool after
|
|
14
|
+
"""Removes the "current_tool"/"pending_approval_tool" markers set by
|
|
15
|
+
api/ui_routes.py while a tool call is being approved/run - without
|
|
16
|
+
this, the bot's presence status stays stuck on the last tool after
|
|
17
|
+
the turn finishes."""
|
|
17
18
|
session = session_manager.get_session(thread_id)
|
|
18
|
-
if
|
|
19
|
-
|
|
19
|
+
if not session:
|
|
20
|
+
return
|
|
21
|
+
changed = False
|
|
22
|
+
for key in ("current_tool", "pending_approval_tool"):
|
|
23
|
+
if key in session:
|
|
24
|
+
del session[key]
|
|
25
|
+
changed = True
|
|
26
|
+
if changed:
|
|
20
27
|
session_manager.set_session(thread_id, session)
|
|
21
28
|
|
|
22
29
|
|
|
23
30
|
class StreamUpdater:
|
|
24
|
-
def __init__(self, thread: Any, context_dict: dict):
|
|
31
|
+
def __init__(self, thread: Any, thread_id: str, context_dict: dict):
|
|
25
32
|
self.MAX_EMBED_LEN = MAX_EMBED_LEN
|
|
26
33
|
self.RATE_LIMIT_SEC = STREAM_RATE_LIMIT_SEC
|
|
27
34
|
self.thread = thread
|
|
35
|
+
self.thread_id = thread_id
|
|
28
36
|
self.context_dict = context_dict
|
|
29
37
|
self.status_msg = None
|
|
30
38
|
self.current_text = ""
|
|
@@ -57,7 +65,7 @@ class StreamUpdater:
|
|
|
57
65
|
self.last_update_time = now
|
|
58
66
|
|
|
59
67
|
async def _update(self, text: str, force_new: bool):
|
|
60
|
-
adapter =
|
|
68
|
+
adapter = get_adapter_for_thread(self.thread_id)
|
|
61
69
|
try:
|
|
62
70
|
if self.status_msg is not None and not force_new:
|
|
63
71
|
if await adapter.edit_message(self.status_msg, text):
|
|
@@ -135,8 +143,8 @@ class TTSStreamManager:
|
|
|
135
143
|
session_manager.set_tts_task(self.thread_id, new_task)
|
|
136
144
|
|
|
137
145
|
|
|
138
|
-
async def stream_thinking_latest(bot, thread: Any, context_dict: dict, queue: asyncio.Queue):
|
|
139
|
-
cog = bot.get_cog("VoiceCog")
|
|
146
|
+
async def stream_thinking_latest(bot, thread: Any, thread_id: str, context_dict: dict, queue: asyncio.Queue):
|
|
147
|
+
cog = bot.get_cog("VoiceCog") if bot else None
|
|
140
148
|
is_voice = bool(
|
|
141
149
|
cog
|
|
142
150
|
and hasattr(thread, "guild")
|
|
@@ -144,8 +152,8 @@ async def stream_thinking_latest(bot, thread: Any, context_dict: dict, queue: as
|
|
|
144
152
|
and cog._voice_state[str(thread.guild.id)] == thread.id
|
|
145
153
|
)
|
|
146
154
|
|
|
147
|
-
ui_mgr = StreamUpdater(thread, context_dict)
|
|
148
|
-
tts_mgr = TTSStreamManager(
|
|
155
|
+
ui_mgr = StreamUpdater(thread, thread_id, context_dict)
|
|
156
|
+
tts_mgr = TTSStreamManager(thread_id, thread.guild.id if hasattr(thread, "guild") else None, cog, is_voice)
|
|
149
157
|
|
|
150
158
|
try:
|
|
151
159
|
while True:
|
|
@@ -173,14 +181,14 @@ async def stream_thinking_latest(bot, thread: Any, context_dict: dict, queue: as
|
|
|
173
181
|
await ui_mgr.flush(force=True)
|
|
174
182
|
if context_dict is not None:
|
|
175
183
|
context_dict["final_text"] = ui_mgr.current_text.strip()
|
|
176
|
-
session_manager.remove_queue(
|
|
177
|
-
_clear_current_tool(
|
|
184
|
+
session_manager.remove_queue(thread_id)
|
|
185
|
+
_clear_current_tool(thread_id)
|
|
178
186
|
await tts_mgr.flush_all()
|
|
179
187
|
break
|
|
180
188
|
|
|
181
189
|
if str(chunk).startswith("__CONV_ID__:"):
|
|
182
190
|
conv_id = chunk.split(":", 1)[1]
|
|
183
|
-
session_manager.update_session(
|
|
191
|
+
session_manager.update_session(thread_id, "conversation_id", conv_id)
|
|
184
192
|
continue
|
|
185
193
|
|
|
186
194
|
if chunk == "__SPLIT__":
|
|
@@ -194,6 +202,6 @@ async def stream_thinking_latest(bot, thread: Any, context_dict: dict, queue: as
|
|
|
194
202
|
await ui_mgr.flush()
|
|
195
203
|
await tts_mgr.process_chunk(chunk)
|
|
196
204
|
except asyncio.CancelledError:
|
|
197
|
-
logger.debug(f"stream_thinking_latest cancelled for thread {
|
|
205
|
+
logger.debug(f"stream_thinking_latest cancelled for thread {thread_id}")
|
|
198
206
|
except Exception as e:
|
|
199
207
|
logger.exception(f"Error in stream_thinking_latest: {e}")
|
package/src/utils/utils.py
CHANGED
|
@@ -7,6 +7,7 @@ from core.agy_runner import (
|
|
|
7
7
|
generate_thread_title,
|
|
8
8
|
get_current_model,
|
|
9
9
|
run_agy,
|
|
10
|
+
update_agy_conversation_title,
|
|
10
11
|
)
|
|
11
12
|
from services.audio_service import stt, tts
|
|
12
13
|
from services.discord_helpers import (
|
|
@@ -23,6 +24,7 @@ __all__ = [
|
|
|
23
24
|
"agy_send_message",
|
|
24
25
|
"get_current_model",
|
|
25
26
|
"generate_thread_title",
|
|
27
|
+
"update_agy_conversation_title",
|
|
26
28
|
"active_processes",
|
|
27
29
|
"agy_start_lock",
|
|
28
30
|
"tts",
|
package/voice-service/index.js
CHANGED
|
@@ -271,19 +271,8 @@ function loadRustpotterModule() {
|
|
|
271
271
|
// userId -> { rustpotter, samplesPerFrame, residual: Int16Array }
|
|
272
272
|
const detectorCache = new Map();
|
|
273
273
|
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
// rustpotter's confirmation logic re-arms its countdown EVERY time a
|
|
277
|
-
// new "candidate" clears the threshold (detector.rs's run_detection:
|
|
278
|
-
// `self.detection_countdown = self.max_mfcc_frames / 2` runs again on
|
|
279
|
-
// every qualifying frame). With this set to 0.05 during earlier
|
|
280
|
-
// debugging, silence and noise cleared it just as easily as real
|
|
281
|
-
// speech, so the countdown never ran out and nothing was ever
|
|
282
|
-
// confirmed no matter how much silence padding was fed afterward.
|
|
283
|
-
// Verified against a native Rust reproduction of this exact detector
|
|
284
|
-
// before settling on 0.5 (matches rustpotter's own default, and what
|
|
285
|
-
// rustpotter-cli scored real captured audio at: 0.55-0.73).
|
|
286
|
-
const WAKE_MATCH_THRESHOLD = 0.5;
|
|
274
|
+
// Wake-word confirm cutoff - must stay well above ~0.05 (rustpotter's countdown never finalizes if noise/silence clears it too); 0.4 chosen after live use kept narrowly missing genuine hits just under 0.5.
|
|
275
|
+
const WAKE_MATCH_THRESHOLD = 0.4;
|
|
287
276
|
|
|
288
277
|
async function getDetectorForUser(userId) {
|
|
289
278
|
if (detectorCache.has(userId)) return detectorCache.get(userId);
|
|
@@ -661,20 +650,17 @@ function setupReceiver(connection, guildId) {
|
|
|
661
650
|
bestDiagScoreName = diagPaddingDetection.getName();
|
|
662
651
|
}
|
|
663
652
|
|
|
664
|
-
//
|
|
665
|
-
// comment for why this has to be a meaningful cutoff.
|
|
666
|
-
// bestDiagScore comes from the separate diagnostic
|
|
667
|
-
// instance above (see its comment in getDetectorForUser)
|
|
668
|
-
// so a "no match" line still shows the real closest
|
|
669
|
-
// score instead of a meaningless flat 0.000.
|
|
653
|
+
// Real pass/fail uses bestWakeScore; bestDiagScore is a separate, much looser detector shown only for "how close" - not on the same scale, not comparable to WAKE_MATCH_THRESHOLD.
|
|
670
654
|
wakeConfirmed = bestWakeScore >= WAKE_MATCH_THRESHOLD;
|
|
671
655
|
matchedWakeWord = wakeConfirmed ? bestWakeScoreName : null;
|
|
672
656
|
console.log(
|
|
673
657
|
wakeConfirmed
|
|
674
658
|
? `[Wake] ${userId}: CONFIRMED (score ${bestWakeScore.toFixed(3)} for ` +
|
|
675
659
|
`"${bestWakeScoreName}", threshold ${WAKE_MATCH_THRESHOLD})`
|
|
676
|
-
: `[Wake] ${userId}: no match (
|
|
677
|
-
`
|
|
660
|
+
: `[Wake] ${userId}: no match (score ${bestWakeScore.toFixed(3)}, ` +
|
|
661
|
+
`threshold ${WAKE_MATCH_THRESHOLD}; diagnostic-only closeness ` +
|
|
662
|
+
`${bestDiagScore.toFixed(3)} for "${bestDiagScoreName ?? 'n/a'}" - ` +
|
|
663
|
+
`different scoring config, not directly comparable to the threshold)`,
|
|
678
664
|
);
|
|
679
665
|
}
|
|
680
666
|
|
|
@@ -702,7 +688,8 @@ function setupReceiver(connection, guildId) {
|
|
|
702
688
|
return;
|
|
703
689
|
}
|
|
704
690
|
|
|
705
|
-
|
|
691
|
+
// Was 24000 (250ms) - cut off short Korean replies ("네"/"어"/"응"); noise is filtered upstream by isSpeaking's RMS/sustain check, not by duration.
|
|
692
|
+
if (pcmBuffer.length < 9600) {
|
|
706
693
|
if (partialSent) {
|
|
707
694
|
fetch('http://127.0.0.1:18080/stt_partial_cancel', {
|
|
708
695
|
method: 'POST',
|
|
@@ -1036,6 +1023,22 @@ process.on('uncaughtException', (err) => {
|
|
|
1036
1023
|
process.exit(1);
|
|
1037
1024
|
});
|
|
1038
1025
|
|
|
1026
|
+
// Without this, SIGTERM (lgy stop/restart) kills the process mid-connection and Discord never gets a clean leave.
|
|
1027
|
+
function shutdownGracefully() {
|
|
1028
|
+
console.log(`[Shutdown] Disconnecting from ${connections.size} active voice connection(s)...`);
|
|
1029
|
+
for (const connection of connections.values()) {
|
|
1030
|
+
try {
|
|
1031
|
+
connection.destroy();
|
|
1032
|
+
} catch (e) {
|
|
1033
|
+
// already destroyed/disconnected - fine
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
process.exit(0);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
process.on('SIGTERM', shutdownGracefully);
|
|
1040
|
+
process.on('SIGINT', shutdownGracefully);
|
|
1041
|
+
|
|
1039
1042
|
const PORT = 18081;
|
|
1040
1043
|
app.listen(PORT, '0.0.0.0', () => {
|
|
1041
1044
|
console.log(`Node.js Voice API listening on port ${PORT}`);
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
|
-
import requests
|
|
4
|
-
from mcp.server.fastmcp import FastMCP
|
|
5
|
-
|
|
6
|
-
mcp = FastMCP("DiscordButtons")
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@mcp.tool()
|
|
10
|
-
def ask_discord_user(question: str, options: list[str]) -> str:
|
|
11
|
-
"""
|
|
12
|
-
Ask a multiple-choice question to the user in Discord using interactive buttons.
|
|
13
|
-
You MUST use this tool instead of the default ask_question tool when running in Discord.
|
|
14
|
-
"""
|
|
15
|
-
thread_id = os.environ.get("DISCORD_THREAD_ID")
|
|
16
|
-
if not thread_id:
|
|
17
|
-
return "Error: DISCORD_THREAD_ID not set. Are you running in Discord?"
|
|
18
|
-
|
|
19
|
-
try:
|
|
20
|
-
resp = requests.post(
|
|
21
|
-
"http://127.0.0.1:18080/mcp_ask",
|
|
22
|
-
json={"thread_id": thread_id, "question": question, "options": options},
|
|
23
|
-
timeout=300,
|
|
24
|
-
)
|
|
25
|
-
if resp.status_code == 200:
|
|
26
|
-
return resp.json().get("answer", "No answer")
|
|
27
|
-
return f"Error: HTTP {resp.status_code}"
|
|
28
|
-
except Exception as e:
|
|
29
|
-
return f"Error: {e}"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@mcp.tool()
|
|
33
|
-
def send_discord_message(channel_id: str, message: str) -> str:
|
|
34
|
-
"""
|
|
35
|
-
Sends a text message to a specific Discord channel by its ID.
|
|
36
|
-
You can use this tool when the user asks you to send a message to a different channel.
|
|
37
|
-
"""
|
|
38
|
-
try:
|
|
39
|
-
resp = requests.post(
|
|
40
|
-
"http://127.0.0.1:18080/mcp_send_channel", json={"channel_id": channel_id, "message": message}, timeout=10
|
|
41
|
-
)
|
|
42
|
-
if resp.status_code == 200:
|
|
43
|
-
return "Message sent successfully"
|
|
44
|
-
return f"Error: HTTP {resp.status_code} - {resp.text}"
|
|
45
|
-
except Exception as e:
|
|
46
|
-
return f"Error: {e}"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if __name__ == "__main__":
|
|
50
|
-
mcp.run()
|