main
py 406 lines 12.1 KB
Raw
1 from __future__ import annotations
2
3 import re
4 import time
5
6 from agent import AgentContext
7 from helpers.print_style import PrintStyle
8 from plugins._telegram_integration.helpers import telegram_client as tc
9 from plugins._telegram_integration.helpers.bot_manager import get_bot
10 from plugins._telegram_integration.helpers.constants import (
11 CTX_TG_BOT,
12 CTX_TG_CHAT_ID,
13 CTX_TG_PROGRESS_LINES,
14 CTX_TG_PROGRESS_MESSAGE_ID,
15 CTX_TG_REPLY_TO,
16 CTX_TG_RESPONSE_LAST_UPDATE,
17 CTX_TG_RESPONSE_MESSAGE_ID,
18 CTX_TG_RESPONSE_TEXT,
19 CTX_TG_STREAM_ENABLED,
20 CTX_TG_TOOLS_ENABLED,
21 CTX_TG_ERROR_SENT,
22 )
23
24 MAX_STREAM_CHARS: int = 3900
25 MIN_RESPONSE_UPDATE_SECONDS: float = 1.0
26 MAX_PROGRESS_LINES: int = 12
27
28 TOOL_EMOJIS: dict[str, str] = {
29 "browser": "🌐",
30 "code": "⌨️",
31 "code_execution_tool": "⌨️",
32 "duckduckgo_search": "🔎",
33 "read_file": "📖",
34 "file": "📄",
35 "knowledge_tool": "📚",
36 "memory": "🧠",
37 "search": "🔎",
38 "search_engine": "🔎",
39 "search_files": "🔎",
40 "skill": "📚",
41 "skill_view": "📚",
42 }
43
44
45 async def start(context: AgentContext) -> None:
46 context.data.pop(CTX_TG_ERROR_SENT, None)
47 # Do not pre-create a placeholder Telegram message. Wait until we have
48 # real assistant text so the stream starts with meaningful content.
49 if not _stream_enabled(context):
50 return
51
52
53 async def add_tool_start(
54 context: AgentContext,
55 tool_name: str,
56 args: dict | None = None,
57 ) -> None:
58 if not _tools_enabled(context):
59 return
60 label = _tool_progress_label(tool_name, args or {})
61 _append_progress_line(context, f"{_tool_emoji(tool_name)} {label}")
62 await _send_progress(context)
63
64
65 async def add_tool_done(context: AgentContext, tool_name: str, ok: bool = True) -> None:
66 if not _tools_enabled(context):
67 return
68 if ok:
69 return
70 _mark_tool_failed(context, tool_name)
71 await _send_progress(context)
72
73
74 async def update_response(context: AgentContext, response_text: str) -> None:
75 if not _stream_enabled(context):
76 return
77 cleaned = _visible_response_text(response_text)
78 if not cleaned:
79 return
80 context.data[CTX_TG_RESPONSE_TEXT] = response_text or ""
81 now = time.time()
82 last = float(context.data.get(CTX_TG_RESPONSE_LAST_UPDATE) or 0.0)
83 if now - last < MIN_RESPONSE_UPDATE_SECONDS:
84 return
85 await _update_response_message(context, response_text or "")
86 context.data[CTX_TG_RESPONSE_LAST_UPDATE] = now
87
88
89 async def send_intermediate_response(
90 context: AgentContext,
91 response_text: str,
92 keyboard: list[list[dict]] | None = None,
93 ) -> bool:
94 if context.data.get(CTX_TG_RESPONSE_MESSAGE_ID):
95 sent = await finalize_response(context, response_text, keyboard)
96 if sent:
97 _reset_progress_group(context)
98 return sent
99
100 html = _format_response(response_text)
101 if not html:
102 return False
103 bot = _bot_instance(context)
104 chat_id = context.data.get(CTX_TG_CHAT_ID)
105 if not bot or not chat_id:
106 return False
107 try:
108 sent_id = await tc.raw_send_text(
109 bot.bot.token,
110 int(chat_id),
111 html,
112 parse_mode="HTML",
113 reply_markup=_keyboard_markup(keyboard),
114 )
115 sent = bool(sent_id)
116 if sent:
117 _reset_progress_group(context)
118 return sent
119 except Exception as e:
120 PrintStyle.debug(f"Telegram intermediate response failed: {e}")
121 return False
122
123
124 async def finalize_response(
125 context: AgentContext,
126 response_text: str,
127 keyboard: list[list[dict]] | None = None,
128 ) -> bool:
129 message_id = context.data.get(CTX_TG_RESPONSE_MESSAGE_ID)
130 if not message_id:
131 return False
132 ok = await _update_response_message(
133 context,
134 response_text or context.data.get(CTX_TG_RESPONSE_TEXT) or "",
135 keyboard=keyboard,
136 force=True,
137 )
138 if ok:
139 context.data.pop(CTX_TG_RESPONSE_MESSAGE_ID, None)
140 context.data.pop(CTX_TG_RESPONSE_TEXT, None)
141 context.data.pop(CTX_TG_RESPONSE_LAST_UPDATE, None)
142 return ok
143
144
145 def clear(context: AgentContext) -> None:
146 for key in (
147 CTX_TG_PROGRESS_LINES,
148 CTX_TG_PROGRESS_MESSAGE_ID,
149 CTX_TG_RESPONSE_MESSAGE_ID,
150 CTX_TG_RESPONSE_TEXT,
151 CTX_TG_RESPONSE_LAST_UPDATE,
152 ):
153 context.data.pop(key, None)
154
155
156 def _reset_progress_group(context: AgentContext) -> None:
157 context.data.pop(CTX_TG_PROGRESS_LINES, None)
158 context.data.pop(CTX_TG_PROGRESS_MESSAGE_ID, None)
159
160
161 def _stream_enabled(context: AgentContext) -> bool:
162 value = context.get_data(CTX_TG_STREAM_ENABLED)
163 return True if value is None else bool(value)
164
165
166 def _tools_enabled(context: AgentContext) -> bool:
167 value = context.get_data(CTX_TG_TOOLS_ENABLED)
168 return True if value is None else bool(value)
169
170
171 async def _send_progress(context: AgentContext) -> None:
172 bot = _bot_instance(context)
173 chat_id = context.data.get(CTX_TG_CHAT_ID)
174 if not bot or not chat_id:
175 return
176 text = "\n".join(context.data.get(CTX_TG_PROGRESS_LINES) or [])
177 if not text:
178 return
179 message_id = context.data.get(CTX_TG_PROGRESS_MESSAGE_ID)
180 try:
181 if message_id:
182 await tc.raw_edit_text(bot.bot.token, int(chat_id), int(message_id), text, parse_mode=None)
183 return
184 sent_id = await tc.raw_send_text(
185 bot.bot.token,
186 int(chat_id),
187 text,
188 parse_mode=None,
189 )
190 if sent_id:
191 context.data[CTX_TG_PROGRESS_MESSAGE_ID] = sent_id
192 except Exception as e:
193 PrintStyle.debug(f"Telegram progress update failed: {e}")
194
195
196 async def _ensure_response_message(context: AgentContext, text: str) -> int | None:
197 message_id = context.data.get(CTX_TG_RESPONSE_MESSAGE_ID)
198 if message_id:
199 return int(message_id)
200 html = _format_response(text)
201 if not html:
202 return None
203 bot = _bot_instance(context)
204 chat_id = context.data.get(CTX_TG_CHAT_ID)
205 if not bot or not chat_id:
206 return None
207 sent_id = await tc.raw_send_text(
208 bot.bot.token,
209 int(chat_id),
210 html,
211 reply_to_message_id=_reply_to(context),
212 parse_mode="HTML",
213 )
214 if sent_id:
215 context.data[CTX_TG_RESPONSE_MESSAGE_ID] = sent_id
216 context.data[CTX_TG_RESPONSE_LAST_UPDATE] = time.time()
217 return sent_id
218
219
220 async def _update_response_message(
221 context: AgentContext,
222 text: str,
223 *,
224 keyboard: list[list[dict]] | None = None,
225 force: bool = False,
226 ) -> bool:
227 had_message = bool(context.data.get(CTX_TG_RESPONSE_MESSAGE_ID))
228 message_id = await _ensure_response_message(context, text)
229 bot = _bot_instance(context)
230 chat_id = context.data.get(CTX_TG_CHAT_ID)
231 if not message_id or not bot or not chat_id:
232 return False
233 markup = _keyboard_markup(keyboard)
234 html = _format_response(text)
235 if not had_message and not markup and not force:
236 return True
237 try:
238 ok = await tc.raw_edit_text(
239 bot.bot.token,
240 int(chat_id),
241 int(message_id),
242 html,
243 parse_mode="HTML",
244 reply_markup=markup,
245 )
246 if ok or force:
247 return ok
248 return False
249 except Exception as e:
250 PrintStyle.debug(f"Telegram response update failed: {e}")
251 return False
252
253
254 def _append_progress_line(context: AgentContext, line: str) -> None:
255 lines = list(context.data.get(CTX_TG_PROGRESS_LINES) or [])
256 if lines and lines[-1] == line:
257 return
258 lines.append(line)
259 context.data[CTX_TG_PROGRESS_LINES] = lines[-MAX_PROGRESS_LINES:]
260
261
262 def _mark_tool_failed(context: AgentContext, tool_name: str) -> None:
263 lines = list(context.data.get(CTX_TG_PROGRESS_LINES) or [])
264 if not lines:
265 return
266
267 label = _tool_label(tool_name)
268 for index in range(len(lines) - 1, -1, -1):
269 line = lines[index]
270 if _line_matches_tool(line, label):
271 _, _, suffix = line.partition(" ")
272 lines[index] = f"{suffix or label}"
273 context.data[CTX_TG_PROGRESS_LINES] = lines[-MAX_PROGRESS_LINES:]
274 return
275
276 lines.append(f"{label}")
277 context.data[CTX_TG_PROGRESS_LINES] = lines[-MAX_PROGRESS_LINES:]
278
279
280 def _bot_instance(context: AgentContext):
281 bot_name = context.data.get(CTX_TG_BOT)
282 return get_bot(bot_name) if bot_name else None
283
284
285 def _reply_to(context: AgentContext) -> int | None:
286 value = context.data.get(CTX_TG_REPLY_TO)
287 try:
288 return int(value) if value else None
289 except (TypeError, ValueError):
290 return None
291
292
293 def _tool_label(tool_name: str) -> str:
294 value = (tool_name or "tool").replace("_", " ").replace("-", " ").strip()
295 return value or "tool"
296
297
298 def _line_matches_tool(line: str, label: str) -> bool:
299 _, _, suffix = line.partition(" ")
300 normalized_suffix = suffix.strip().lower()
301 normalized_label = label.strip().lower()
302 return normalized_suffix == normalized_label or normalized_suffix.startswith(f"{normalized_label}:")
303
304
305 def _tool_progress_label(tool_name: str, args: dict) -> str:
306 label = _tool_label(tool_name)
307 detail = _tool_detail(tool_name, args)
308 return f"{label}: {detail}" if detail else label
309
310
311 def _tool_detail(tool_name: str, args: dict) -> str:
312 if not isinstance(args, dict) or not args:
313 return ""
314
315 normalized = (tool_name or "").strip().lower()
316 if "search" in normalized:
317 return _first_arg(args, ("query", "q", "search", "term", "keywords", "pattern"))
318 if "browser" in normalized:
319 return _first_arg(args, ("url", "link", "query", "action"))
320 if "file" in normalized:
321 return _first_arg(args, ("path", "file", "filename", "query", "pattern"))
322 if "skill" in normalized:
323 return _first_arg(args, ("skill", "name", "query", "path"))
324
325 return _first_arg(args, ("action", "method", "operation"))
326
327
328 def _first_arg(args: dict, keys: tuple[str, ...]) -> str:
329 for key in keys:
330 value = args.get(key)
331 if value is None:
332 continue
333 text = _compact_detail(value)
334 if text:
335 return text
336 return ""
337
338
339 def _compact_detail(value: object) -> str:
340 if isinstance(value, (list, tuple)):
341 parts = [_compact_detail(item) for item in value[:2]]
342 text = ", ".join(part for part in parts if part)
343 if len(value) > 2:
344 text = f"{text}, ..."
345 elif isinstance(value, dict):
346 return ""
347 else:
348 text = str(value).strip()
349
350 text = re.sub(r"\s+", " ", text).strip().strip("\"'")
351 if len(text) > 80:
352 text = f"{text[:77].rstrip()}..."
353 return text
354
355
356 def _tool_emoji(tool_name: str) -> str:
357 normalized = (tool_name or "").strip().lower()
358 for key, emoji in TOOL_EMOJIS.items():
359 if key in normalized:
360 return emoji
361 return "🛠️"
362
363
364 def _format_response(text: str) -> str:
365 value = _visible_response_text(text)
366 if not value:
367 return ""
368 return tc.md_to_telegram_html(value)
369
370
371 def _strip_incomplete_tool_markup(text: str) -> str:
372 value = text.lstrip()
373 value = re.sub(r"^<[^>\n]{0,80}$", "", value)
374 value = re.sub(r"^```(?:json|xml)?\s*$", "", value, flags=re.IGNORECASE)
375 return value
376
377
378 def _visible_response_text(text: str) -> str:
379 value = _trim(text or "")
380 return _strip_incomplete_tool_markup(value)
381
382
383 def _trim(text: str) -> str:
384 if len(text) <= MAX_STREAM_CHARS:
385 return text
386 return text[-MAX_STREAM_CHARS:]
387
388
389 def _keyboard_markup(keyboard: list[list[dict]] | None) -> dict | None:
390 if not keyboard:
391 return None
392 rows: list[list[dict[str, str]]] = []
393 for row in keyboard:
394 out_row: list[dict[str, str]] = []
395 for button in row:
396 text = str(button.get("text") or "")[:64]
397 if not text:
398 continue
399 if button.get("url"):
400 out_row.append({"text": text, "url": str(button["url"])})
401 else:
402 data = str(button.get("callback_data", text))
403 out_row.append({"text": text, "callback_data": data[:64]})
404 if out_row:
405 rows.append(out_row)
406 return {"inline_keyboard": rows} if rows else None