linkgravity 1.3.0 → 1.4.1

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,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 &amp; 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.error(f"TTS error: {e}")
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.error(f"STT internal error: {e}")
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(message: discord.Message) -> list[str]:
10
+ async def handle_image_attachments(attachments: list[IncomingAttachment]) -> list[str]:
12
11
  saved_paths = []
13
- for att in message.attachments:
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
@@ -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 get_adapter
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 = get_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,7 +6,7 @@ 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 get_adapter
9
+ from messengers.registry import get_adapter_for_thread
10
10
  from utils.utils import clean_ansi
11
11
 
12
12
 
@@ -28,10 +28,11 @@ def _clear_current_tool(thread_id: str):
28
28
 
29
29
 
30
30
  class StreamUpdater:
31
- def __init__(self, thread: Any, context_dict: dict):
31
+ def __init__(self, thread: Any, thread_id: str, context_dict: dict):
32
32
  self.MAX_EMBED_LEN = MAX_EMBED_LEN
33
33
  self.RATE_LIMIT_SEC = STREAM_RATE_LIMIT_SEC
34
34
  self.thread = thread
35
+ self.thread_id = thread_id
35
36
  self.context_dict = context_dict
36
37
  self.status_msg = None
37
38
  self.current_text = ""
@@ -64,7 +65,7 @@ class StreamUpdater:
64
65
  self.last_update_time = now
65
66
 
66
67
  async def _update(self, text: str, force_new: bool):
67
- adapter = get_adapter()
68
+ adapter = get_adapter_for_thread(self.thread_id)
68
69
  try:
69
70
  if self.status_msg is not None and not force_new:
70
71
  if await adapter.edit_message(self.status_msg, text):
@@ -142,8 +143,8 @@ class TTSStreamManager:
142
143
  session_manager.set_tts_task(self.thread_id, new_task)
143
144
 
144
145
 
145
- async def stream_thinking_latest(bot, thread: Any, context_dict: dict, queue: asyncio.Queue):
146
- 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
147
148
  is_voice = bool(
148
149
  cog
149
150
  and hasattr(thread, "guild")
@@ -151,8 +152,8 @@ async def stream_thinking_latest(bot, thread: Any, context_dict: dict, queue: as
151
152
  and cog._voice_state[str(thread.guild.id)] == thread.id
152
153
  )
153
154
 
154
- ui_mgr = StreamUpdater(thread, context_dict)
155
- tts_mgr = TTSStreamManager(thread.id, thread.guild.id if hasattr(thread, "guild") else None, cog, is_voice)
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)
156
157
 
157
158
  try:
158
159
  while True:
@@ -180,14 +181,14 @@ async def stream_thinking_latest(bot, thread: Any, context_dict: dict, queue: as
180
181
  await ui_mgr.flush(force=True)
181
182
  if context_dict is not None:
182
183
  context_dict["final_text"] = ui_mgr.current_text.strip()
183
- session_manager.remove_queue(str(thread.id))
184
- _clear_current_tool(str(thread.id))
184
+ session_manager.remove_queue(thread_id)
185
+ _clear_current_tool(thread_id)
185
186
  await tts_mgr.flush_all()
186
187
  break
187
188
 
188
189
  if str(chunk).startswith("__CONV_ID__:"):
189
190
  conv_id = chunk.split(":", 1)[1]
190
- session_manager.update_session(str(thread.id), "conversation_id", conv_id)
191
+ session_manager.update_session(thread_id, "conversation_id", conv_id)
191
192
  continue
192
193
 
193
194
  if chunk == "__SPLIT__":
@@ -201,6 +202,6 @@ async def stream_thinking_latest(bot, thread: Any, context_dict: dict, queue: as
201
202
  await ui_mgr.flush()
202
203
  await tts_mgr.process_chunk(chunk)
203
204
  except asyncio.CancelledError:
204
- logger.debug(f"stream_thinking_latest cancelled for thread {thread.id}")
205
+ logger.debug(f"stream_thinking_latest cancelled for thread {thread_id}")
205
206
  except Exception as e:
206
207
  logger.exception(f"Error in stream_thinking_latest: {e}")
@@ -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()