Files
lsdefine--genericagent/frontends/tgapp.py
T
2026-07-13 13:04:19 +08:00

1139 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os, sys, re, threading, asyncio, queue as Q, time, random, uuid
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
from agentmain import GeneraticAgent
try:
from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.constants import ChatType, MessageLimit, ParseMode
from telegram.error import RetryAfter
from telegram.ext import ApplicationBuilder, CallbackQueryHandler, MessageHandler, filters, ContextTypes
from telegram.helpers import escape_markdown
from telegram.request import HTTPXRequest
except:
print("Please ask the agent install python-telegram-bot to use telegram module.")
sys.exit(1)
from chatapp_common import (
FILE_HINT,
HELP_TEXT,
TELEGRAM_MENU_COMMANDS,
clean_reply,
ensure_single_instance,
extract_files,
format_restore,
redirect_log,
require_runtime,
split_text,
)
from continue_cmd import handle_frontend_command, reset_conversation
from btw_cmd import handle_frontend_command as handle_btw_frontend_command
from review_cmd import handle as handle_review_command
from llmcore import mykeys
agent = GeneraticAgent()
agent.verbose = False
agent.inc_out = True
ALLOWED = set(mykeys.get('tg_allowed_users', []))
_DRAFT_HINT = "thinking..."
_STREAM_SUFFIX = " ⏳"
_STREAM_SEGMENT_LIMIT = max(1200, MessageLimit.MAX_TEXT_LENGTH - 256)
_STREAM_UPDATE_INTERVAL_SECONDS = 2.0
_STREAM_MIN_UPDATE_CHARS = 400
_RETRY_AFTER_MARGIN_SECONDS = 1.0
_QUEUE_WAIT_SECONDS = 1
_ASK_USER_HOOK_KEY = "telegram_ask_user_menu"
_ASK_CALLBACK_PREFIX = "ask:"
_LLM_CALLBACK_PREFIX = "llm:"
_ASK_CANCEL_ACTION = "none"
_ASK_MULTI_DONE_ACTION = "done"
_ASK_TOGGLE_ACTION = "toggle"
_ASK_CANCEL_LABEL = "none of these above"
_ASK_CANCEL_PROMPT = "已取消选择,请直接发送下一步操作。"
_ASK_MULTI_HINT = "可多选:点选项目后点击 Done 提交。"
_ASK_MULTI_EMPTY_HINT = "请至少选择一项,或选择 none of these above。"
_LLM_MENU_PROMPT = "请选择要切换的 LLM"
_ask_menu_events = Q.Queue()
_ask_menu_store = {}
_llm_menu_store = {}
_MULTI_SELECT_RE = re.compile(r"\[?(?:多选|multi(?:[-_ ]?select)?|select all)\]?", re.IGNORECASE)
_QUOTE_OPEN_TAG = "<_quote_>"
_QUOTE_CLOSE_TAG = "</_quote_>"
_QUOTE_TOKEN_PATTERN = re.escape(_QUOTE_OPEN_TAG) + r"([\s\S]*?)" + re.escape(_QUOTE_CLOSE_TAG)
_MD_TOKEN_RE = re.compile(
(
r"(`{3,})([A-Za-z0-9_+-]*)\n([\s\S]*?)\1"
r"|" + _QUOTE_TOKEN_PATTERN +
r"|\[([^\]]+)\]\(([^)\n]+)\)"
r"|`([^`\n]+)`"
r"|\*\*([^\n]+?)\*\*"
r"|__([^\n]+?)__"
r"|~~([^\n]+?)~~"
r"|(?<!\*)\*(?!\*)([^\n]+?)(?<!\*)\*(?!\*)"
),
re.DOTALL,
)
_TURN_MARKER_RE = re.compile(r"^\*{0,2}LLM Running \(Turn (\d+)\) \.\.\.\*{0,2}\s*$")
_CODE_FENCE_RE = re.compile(r"^\s*(`{3,})(.*)$")
_TURN_SUMMARY_LIMIT = 160
_TURN_SUMMARY_RE = re.compile(r"<summary>\s*(.*?)\s*</summary>", re.DOTALL)
_TURN_SUMMARY_SEARCH_STRIP_RE = re.compile(r"`{3,}[\s\S]*?`{3,}|<thinking>[\s\S]*?</thinking>", re.DOTALL)
def _make_draft_id():
return random.randint(1, 2**31 - 1)
def _visible_segments(text):
text = (text or "").strip()
if not text:
return []
segments = []
for part in split_text(text, _STREAM_SEGMENT_LIMIT):
segments.extend(_markdown_safe_segments(part))
return segments
def _markdown_safe_segments(text, limit=None):
limit = limit or MessageLimit.MAX_TEXT_LENGTH
text = (text or "").strip()
if not text:
return []
if len(_to_markdown_v2(text)) <= limit:
return [text]
parts = []
remaining = text
while remaining:
if len(_to_markdown_v2(remaining)) <= limit:
parts.append(remaining)
break
low, high, best = 1, len(remaining), 1
while low <= high:
mid = (low + high) // 2
if len(_to_markdown_v2(remaining[:mid].rstrip() or remaining[:mid])) <= limit:
best = mid
low = mid + 1
else:
high = mid - 1
cut = remaining.rfind("\n", 0, best)
if cut < max(1, best * 0.6):
cut = best
chunk = remaining[:cut].rstrip() or remaining[:best]
parts.append(chunk)
remaining = remaining[len(chunk):].lstrip()
return parts
def _line_complete(line):
return (line or "").endswith(("\n", "\r"))
def _turn_marker_number(line):
match = _TURN_MARKER_RE.fullmatch((line or "").strip())
return int(match.group(1)) if match else None
def _maybe_partial_turn_marker(line):
text = (line or "").strip().lstrip("*")
if not text:
return False
marker_head = "LLM Running (Turn "
return marker_head.startswith(text) or text.startswith(marker_head)
def _maybe_partial_code_fence(line):
return bool(re.match(r"^\s*`{1,}[^`\r\n]*$", line or ""))
def _extract_turn_summary(raw_text):
search_text = _TURN_SUMMARY_SEARCH_STRIP_RE.sub("", raw_text or "")
match = _TURN_SUMMARY_RE.search(search_text)
if not match:
return ""
summary = re.sub(r"\s+", " ", match.group(1)).strip()
if len(summary) > _TURN_SUMMARY_LIMIT:
summary = summary[:_TURN_SUMMARY_LIMIT - 3].rstrip() + "..."
return summary
def _quote_tag(text):
safe_text = (text or "").strip().replace(_QUOTE_OPEN_TAG, "").replace(_QUOTE_CLOSE_TAG, "")
return f"{_QUOTE_OPEN_TAG}{safe_text}{_QUOTE_CLOSE_TAG}"
def _inject_turn_summary(body, summary):
if not (body or "").strip() or not (summary or "").strip():
return body
lines = (body or "").splitlines()
if not lines or _turn_marker_number(lines[0]) is None:
return body
title = lines[0].strip()
rest = "\n".join(lines[1:]).strip()
summary_line = _quote_tag(summary)
if rest:
return f"{title}\n\n{summary_line}\n\n{rest}"
return f"{title}\n\n{summary_line}"
def _resolve_files(paths):
files, seen = [], set()
for fpath in paths:
if not os.path.isabs(fpath):
fpath = os.path.join(_TEMP_DIR, fpath)
if fpath in seen or not os.path.exists(fpath):
continue
files.append(fpath)
seen.add(fpath)
return files
def _render_file_markers(text):
def repl(match):
return os.path.basename(match.group(1))
return re.sub(r"\[FILE:([^\]]+)\]", repl, text or "").strip()
def _files_from_text(text):
cleaned = clean_reply(text) if (text or "").strip() else ""
return _resolve_files(extract_files(cleaned))
async def _send_files(root_msg, files):
for fpath in files:
if fpath.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")):
try:
with open(fpath, "rb") as fp:
await root_msg.reply_photo(fp)
except Exception:
pass
else:
try:
with open(fpath, "rb") as fp:
await root_msg.reply_document(fp)
except Exception:
pass
async def _send_files_from_text(root_msg, text):
await _send_files(root_msg, _files_from_text(text))
def _escape_pre(text):
return escape_markdown(text or "", version=2, entity_type="pre")
def _escape_code(text):
return escape_markdown(text or "", version=2, entity_type="code")
def _escape_link_target(text):
return escape_markdown(text or "", version=2, entity_type="text_link")
def _quote_to_markdown_v2(text):
lines = (text or "").strip().splitlines() or [""]
return "\n".join(f"> {escape_markdown(line, version=2)}" for line in lines)
def _to_markdown_v2(text):
if not text:
return ""
parts, pos = [], 0
for match in _MD_TOKEN_RE.finditer(text):
parts.append(escape_markdown(text[pos:match.start()], version=2))
if match.group(1):
lang = re.sub(r"[^A-Za-z0-9_+-]", "", match.group(2) or "")
code = _escape_pre(match.group(3) or "")
header = f"```{lang}\n" if lang else "```\n"
parts.append(f"{header}{code}\n```")
elif match.group(4) is not None:
parts.append(_quote_to_markdown_v2(match.group(4)))
elif match.group(5) is not None:
label = escape_markdown(match.group(5), version=2)
target = _escape_link_target(match.group(6))
parts.append(f"[{label}]({target})")
elif match.group(7) is not None:
parts.append(f"`{_escape_code(match.group(7))}`")
elif match.group(8) is not None:
parts.append(f"*{escape_markdown(match.group(8), version=2)}*")
elif match.group(9) is not None:
parts.append(f"*{escape_markdown(match.group(9), version=2)}*")
elif match.group(10) is not None:
parts.append(f"~{escape_markdown(match.group(10), version=2)}~")
elif match.group(11) is not None:
parts.append(f"_{escape_markdown(match.group(11), version=2)}_")
pos = match.end()
parts.append(escape_markdown(text[pos:], version=2))
return "".join(parts)
def _is_not_modified_error(exc):
return "not modified" in str(exc).lower()
def _extract_ask_user_event(ctx):
exit_reason = (ctx or {}).get("exit_reason") or {}
if exit_reason.get("result") != "EXITED":
return None
payload = exit_reason.get("data")
if not isinstance(payload, dict):
return None
if payload.get("status") != "INTERRUPT" or payload.get("intent") != "HUMAN_INTERVENTION":
return None
data = payload.get("data")
if not isinstance(data, dict):
return None
raw_candidates = data.get("candidates") or []
if not isinstance(raw_candidates, (list, tuple)):
return None
candidates = []
for candidate in raw_candidates:
if candidate is None:
continue
text = str(candidate).strip()
if text:
candidates.append(text)
if not candidates:
return None
question = str(data.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:"
return {
"question": question,
"candidates": candidates,
"multi": bool(_MULTI_SELECT_RE.search(question)),
}
def _register_ask_user_hook():
if not hasattr(agent, "_turn_end_hooks"):
agent._turn_end_hooks = {}
def _hook(ctx):
event = _extract_ask_user_event(ctx)
if event:
_ask_menu_events.put(event)
agent._turn_end_hooks[_ASK_USER_HOOK_KEY] = _hook
def _drain_latest_ask_user_event():
latest = None
while True:
try:
latest = _ask_menu_events.get_nowait()
except Q.Empty:
break
return latest
def _build_ask_user_markup(menu_id, candidates, multi=False, selected_indexes=None):
selected_indexes = set(selected_indexes or [])
rows = []
for idx, candidate in enumerate(candidates):
if multi:
label = f"✓ {candidate}" if idx in selected_indexes else candidate
action = f"{_ASK_TOGGLE_ACTION}:{idx}"
else:
label = candidate
action = str(idx)
rows.append([
InlineKeyboardButton(label, callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{action}")
])
if multi:
rows.append([
InlineKeyboardButton("Done", callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{_ASK_MULTI_DONE_ACTION}")
])
rows.append([
InlineKeyboardButton(_ASK_CANCEL_LABEL, callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{_ASK_CANCEL_ACTION}")
])
return InlineKeyboardMarkup(rows)
def _build_llm_markup(menu_id, llms):
rows = []
for idx, name, current in llms:
label = f"→ [{idx}] {name}" if current else f"[{idx}] {name}"
rows.append([
InlineKeyboardButton(label, callback_data=f"{_LLM_CALLBACK_PREFIX}{menu_id}:{idx}")
])
return InlineKeyboardMarkup(rows)
def _parse_menu_callback_data(data, prefix):
if not (data or "").startswith(prefix):
return None, None
payload = data[len(prefix):]
menu_id, sep, action = payload.partition(":")
if not sep or not menu_id or not action:
return None, None
return menu_id, action
def _parse_ask_callback_data(data):
return _parse_menu_callback_data(data, _ASK_CALLBACK_PREFIX)
def _build_text_prompt(text):
return f"{FILE_HINT}\n\n{text}"
def _normalize_ask_menu_event(stored):
if isinstance(stored, dict):
candidates = stored.get("candidates") or []
return {
"question": str(stored.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:",
"candidates": [str(candidate).strip() for candidate in candidates if str(candidate).strip()],
"multi": bool(stored.get("multi")),
"selected": [int(idx) for idx in stored.get("selected", []) if isinstance(idx, int)],
}
if isinstance(stored, (list, tuple)):
return {
"question": "请选择下一步操作:",
"candidates": [str(candidate).strip() for candidate in stored if str(candidate).strip()],
"multi": False,
"selected": [],
}
return None
def _render_ask_user_result(event, selected=None, cancelled=False):
question = str(event.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:"
candidates = event.get("candidates") or []
lines = [question, "", "选项:"]
for idx, candidate in enumerate(candidates, start=1):
lines.append(f"{idx}. {candidate}")
lines.append(f"{len(candidates) + 1}. {_ASK_CANCEL_LABEL}")
lines.append("")
if cancelled:
lines.append(f"已取消:{_ASK_CANCEL_LABEL}")
elif selected:
lines.append(f"已选择:{selected}")
text = "\n".join(lines)
if len(text) > MessageLimit.MAX_TEXT_LENGTH:
text = text[:MessageLimit.MAX_TEXT_LENGTH - 18].rstrip() + "\n...[truncated]"
return text
async def _clear_ask_reply_markup(query):
try:
await query.edit_message_reply_markup(reply_markup=None)
except Exception as exc:
print(f"[TG ask_user menu cleanup] {type(exc).__name__}: {exc}", flush=True)
async def _edit_ask_user_result(query, event, selected=None, cancelled=False):
try:
await query.edit_message_text(
_render_ask_user_result(event, selected=selected, cancelled=cancelled),
reply_markup=None,
)
except Exception as exc:
print(f"[TG ask_user menu edit] {type(exc).__name__}: {exc}", flush=True)
await _clear_ask_reply_markup(query)
async def _send_ask_user_menu(root_msg, event):
menu_id = uuid.uuid4().hex[:16]
candidates = event["candidates"]
multi = bool(event.get("multi"))
_ask_menu_store[menu_id] = {
"question": event["question"],
"candidates": list(candidates),
"multi": multi,
"selected": [],
}
prompt = f"{event['question']}\n\n{_ASK_MULTI_HINT}" if multi else event["question"]
try:
await root_msg.reply_text(
prompt,
reply_markup=_build_ask_user_markup(menu_id, candidates, multi=multi),
)
except Exception as exc:
_ask_menu_store.pop(menu_id, None)
print(f"[TG ask_user menu error] {type(exc).__name__}: {exc}", flush=True)
fallback = event["question"] + "\n" + "\n".join(f"- {candidate}" for candidate in candidates)
await root_msg.reply_text(fallback)
class _TelegramStreamSession:
def __init__(self, root_msg):
self.root_msg = root_msg
self.private_chat = getattr(getattr(root_msg, "chat", None), "type", "") == ChatType.PRIVATE
self.can_use_draft = self.private_chat # update tg client!
self.draft_id = _make_draft_id()
self.live_msg = None
self.raw_text = ""
self.files = []
self.sent_segments = 0
self.active_display = ""
self.pending_display = ""
self._edit_overflow_msgs = {}
self.retry_until = 0.0
self.last_update_at = 0.0
self.last_update_raw_len = 0
def _now(self):
return time.monotonic()
def _retry_after_seconds(self, exc):
retry_after = getattr(exc, "_retry_after", None)
if retry_after is None:
retry_after = getattr(exc, "retry_after", 0) or 0
if hasattr(retry_after, "total_seconds"):
retry_after = retry_after.total_seconds()
try:
return max(0.0, float(retry_after))
except (TypeError, ValueError):
return 0.0
def _set_retry_after(self, exc):
wait_seconds = self._retry_after_seconds(exc) + _RETRY_AFTER_MARGIN_SECONDS
self.retry_until = max(self.retry_until, self._now() + wait_seconds)
def _is_retrying(self):
return self._now() < self.retry_until
async def _wait_for_retry(self):
remaining = self.retry_until - self._now()
if remaining > 0:
await asyncio.sleep(remaining)
def _should_stream_update(self, display):
if display == self.active_display:
return False
if self.last_update_at <= 0:
return True
elapsed = self._now() - self.last_update_at
raw_delta = len(self.raw_text) - self.last_update_raw_len
return elapsed >= _STREAM_UPDATE_INTERVAL_SECONDS or raw_delta >= _STREAM_MIN_UPDATE_CHARS
def _mark_stream_update(self, display):
self.active_display = display
self.pending_display = ""
self.last_update_at = self._now()
self.last_update_raw_len = len(self.raw_text)
def _stream_display(self, text):
base = (text or _DRAFT_HINT).strip() or _DRAFT_HINT
safe_parts = _markdown_safe_segments(base)
base = safe_parts[-1] if safe_parts else _DRAFT_HINT
if base == _DRAFT_HINT:
return base
display = base + _STREAM_SUFFIX
if len(_to_markdown_v2(display)) <= MessageLimit.MAX_TEXT_LENGTH:
return display
return base
async def prime(self):
if self.can_use_draft:
draft_result = await self._send_draft(_DRAFT_HINT)
if draft_result is True:
self.active_display = _DRAFT_HINT
return
if draft_result is None:
self.active_display = _DRAFT_HINT
return
try:
await self._upsert_live_message(_DRAFT_HINT, wait_retry=False)
except RetryAfter:
self.active_display = _DRAFT_HINT
return
self.active_display = _DRAFT_HINT
async def add_chunk(self, chunk):
if not chunk:
return
self.raw_text += chunk
await self._refresh(done=False, send_files=False)
async def finalize(self, full_text=None, send_files=True):
if full_text is not None:
self.raw_text = full_text
await self._refresh(done=True, send_files=send_files)
async def finish_with_notice(self, notice):
if self.raw_text.strip():
await self.finalize(send_files=False)
await self._reply_text(notice)
return
if self.live_msg is not None:
await self._edit_text(self.live_msg, notice)
self.live_msg = None
self.active_display = ""
return
await self._reply_text(notice)
self.active_display = ""
async def _refresh(self, done, send_files):
summary = _extract_turn_summary(self.raw_text)
cleaned = clean_reply(self.raw_text) if self.raw_text.strip() else ""
self.files = _files_from_text(cleaned)
body = _inject_turn_summary(_render_file_markers(cleaned), summary)
if done and not body and self.files:
body = "已生成附件"
elif done and not body:
body = "..."
segments = _visible_segments(body)
finalized_target = len(segments) if done else max(len(segments) - 1, 0)
while self.sent_segments < finalized_target:
await self._finalize_segment(segments[self.sent_segments])
self.sent_segments += 1
if done:
if send_files:
await self._send_files()
return
active_text = segments[-1] if segments else _DRAFT_HINT
await self._stream_active(active_text)
async def _stream_active(self, text):
display = self._stream_display(text)
if display == self.active_display:
return
self.pending_display = display
if self._is_retrying() or not self._should_stream_update(display):
return
try:
if self.can_use_draft:
draft_result = await self._send_draft(display)
if draft_result is True:
self._mark_stream_update(display)
return
if draft_result is None:
return
await self._upsert_live_message(display, wait_retry=False)
self._mark_stream_update(display)
except RetryAfter:
return
async def _finalize_segment(self, text):
final_text = (text or "").strip() or "..."
if self.live_msg is not None:
await self._edit_text(self.live_msg, final_text)
self.live_msg = None
else:
await self._reply_text(final_text)
self.active_display = ""
if self.can_use_draft:
self.draft_id = _make_draft_id()
async def _send_files(self):
await _send_files(self.root_msg, self.files)
async def _send_draft(self, text):
try:
await self.root_msg.reply_text_draft(
self.draft_id,
_to_markdown_v2(text),
parse_mode=ParseMode.MARKDOWN_V2,
)
return True
except RetryAfter as exc:
self._set_retry_after(exc)
return None
except Exception as exc:
if _is_not_modified_error(exc):
return True
print(f"[TG draft fallback] {type(exc).__name__}: {exc}", flush=True)
self.can_use_draft = False
self.draft_id = _make_draft_id()
return False
async def _retry_call(self, func, *args):
while True:
await self._wait_for_retry()
try:
return await func(*args)
except RetryAfter as exc:
self._set_retry_after(exc)
async def _reply_text_once(self, text):
markdown = _to_markdown_v2(text)
try:
return await self.root_msg.reply_text(markdown, parse_mode=ParseMode.MARKDOWN_V2)
except RetryAfter as exc:
self._set_retry_after(exc)
raise
except Exception as exc:
if _is_not_modified_error(exc):
return None
try:
return await self.root_msg.reply_text(text)
except RetryAfter as retry_exc:
self._set_retry_after(retry_exc)
raise
async def _reply_text(self, text, wait_retry=True):
last_msg = None
for segment in _markdown_safe_segments(text) or ["..."]:
if wait_retry:
last_msg = await self._retry_call(self._reply_text_once, segment)
else:
last_msg = await self._reply_text_once(segment)
return last_msg
async def _edit_text_once(self, msg, text):
markdown = _to_markdown_v2(text)
try:
updated = await msg.edit_text(markdown, parse_mode=ParseMode.MARKDOWN_V2)
except RetryAfter as exc:
self._set_retry_after(exc)
raise
except Exception as exc:
if _is_not_modified_error(exc):
return msg
try:
updated = await msg.edit_text(text)
except RetryAfter as retry_exc:
self._set_retry_after(retry_exc)
raise
return updated if hasattr(updated, "edit_text") else msg
def _message_key(self, msg):
chat_id = getattr(getattr(msg, "chat", None), "id", None)
message_id = getattr(msg, "message_id", None)
if chat_id is not None and message_id is not None:
return (chat_id, message_id)
if message_id is not None:
return ("message", message_id)
return ("object", id(msg))
async def _delete_text_once(self, msg):
delete = getattr(msg, "delete", None)
if delete is None:
return
try:
result = delete()
if hasattr(result, "__await__"):
await result
except RetryAfter as exc:
self._set_retry_after(exc)
raise
except Exception as exc:
print(f"[TG stale overflow delete error] {type(exc).__name__}: {exc}", flush=True)
async def _delete_text(self, msg, wait_retry=True):
if wait_retry:
await self._retry_call(self._delete_text_once, msg)
else:
await self._delete_text_once(msg)
async def _edit_text(self, msg, text, wait_retry=True):
segments = _markdown_safe_segments(text) or ["..."]
old_key = self._message_key(msg)
overflow_msgs = self._edit_overflow_msgs.get(old_key, [])
if wait_retry:
updated = await self._retry_call(self._edit_text_once, msg, segments[0])
else:
updated = await self._edit_text_once(msg, segments[0])
primary_msg = updated if hasattr(updated, "edit_text") else msg
self._edit_overflow_msgs.pop(old_key, None)
new_overflow_msgs = []
for index, segment in enumerate(segments[1:]):
if index < len(overflow_msgs):
overflow_msg = overflow_msgs[index]
if wait_retry:
edited_overflow = await self._retry_call(self._edit_text_once, overflow_msg, segment)
else:
edited_overflow = await self._edit_text_once(overflow_msg, segment)
new_overflow_msgs.append(
edited_overflow if hasattr(edited_overflow, "edit_text") else overflow_msg
)
else:
new_overflow_msgs.append(await self._reply_text(segment, wait_retry=wait_retry))
for stale_msg in overflow_msgs[len(new_overflow_msgs):]:
await self._delete_text(stale_msg, wait_retry=wait_retry)
if new_overflow_msgs:
self._edit_overflow_msgs[self._message_key(primary_msg)] = new_overflow_msgs
return primary_msg
async def _upsert_live_message(self, text, wait_retry=True):
if self.live_msg is None:
self.live_msg = await self._reply_text(text, wait_retry=wait_retry)
else:
self.live_msg = await self._edit_text(self.live_msg, text, wait_retry=wait_retry)
class _TelegramTurnStreamCoordinator:
def __init__(self, root_msg):
self.root_msg = root_msg
self.session = None
self.pending_line = ""
self.code_fence_len = 0
self.last_turn = 0
async def prime(self):
await self._ensure_session()
async def add_chunk(self, chunk):
if not chunk:
return
text = self.pending_line + chunk
self.pending_line = ""
for line in text.splitlines(keepends=True):
if _line_complete(line):
await self._process_line(line)
elif _maybe_partial_turn_marker(line) or _maybe_partial_code_fence(line):
self.pending_line = line
else:
await self._process_line(line)
async def finalize(self, done_text="", send_files=True):
await self._flush_pending_line()
if self.session is None:
if done_text:
await self._add_to_current(done_text)
elif not self.session.raw_text.strip() and done_text:
await self.session.finalize(done_text, send_files=False)
if send_files:
await _send_files_from_text(self.root_msg, done_text)
return
if self.session is not None:
await self.session.finalize(send_files=False)
if send_files:
await _send_files_from_text(self.root_msg, done_text)
async def finish_with_notice(self, notice):
await self._flush_pending_line()
await self._ensure_session()
await self.session.finish_with_notice(notice)
async def _ensure_session(self):
if self.session is None:
self.session = _TelegramStreamSession(self.root_msg)
await self.session.prime()
async def _start_turn(self, marker):
if self.session is not None and self.session.raw_text.strip():
await self.session.finalize(send_files=False)
self.session = None
await self._ensure_session()
await self.session.add_chunk(marker)
async def _add_to_current(self, text):
if not text:
return
await self._ensure_session()
await self.session.add_chunk(text)
async def _process_line(self, line):
turn_no = _turn_marker_number(line)
if self.code_fence_len == 0 and turn_no == self.last_turn + 1:
self.last_turn = turn_no
await self._start_turn(line)
return
await self._add_to_current(line)
self._update_code_fence(line)
async def _flush_pending_line(self):
if not self.pending_line:
return
line = self.pending_line
self.pending_line = ""
await self._add_to_current(line)
def _update_code_fence(self, line):
match = _CODE_FENCE_RE.match(line or "")
if not match:
return
fence_len = len(match.group(1))
if self.code_fence_len:
if fence_len >= self.code_fence_len:
self.code_fence_len = 0
return
self.code_fence_len = fence_len
async def _stream(dq, msg):
stream = _TelegramTurnStreamCoordinator(msg)
await stream.prime()
try:
while True:
try: first = await asyncio.to_thread(dq.get, True, _QUEUE_WAIT_SECONDS)
except Q.Empty: continue
items = [first]
try:
while True: items.append(dq.get_nowait())
except Q.Empty: pass
done_item = None
for item in items:
chunk = item.get("next", "")
if chunk:
await stream.add_chunk(chunk)
if "done" in item:
done_item = item
break
if done_item is not None:
await stream.finalize(done_item.get("done", ""))
event = _drain_latest_ask_user_event()
if event:
await _send_ask_user_menu(msg, event)
break
except asyncio.CancelledError:
await stream.finish_with_notice("⏹️ 已停止")
except RetryAfter as exc:
print(f"[TG stream retry_after] {type(exc).__name__}: {exc}", flush=True)
if stream.session is not None:
stream.session._set_retry_after(exc)
except Exception as exc:
print(f"[TG stream error] {type(exc).__name__}: {exc}", flush=True)
if stream.session is not None and stream.session._is_retrying():
return
try:
await stream.finish_with_notice(f"❌ 输出失败: {exc}")
except RetryAfter as retry_exc:
print(f"[TG stream error notice retry_after] {type(retry_exc).__name__}: {retry_exc}", flush=True)
def _normalized_command(text):
parts = (text or "").strip().split(None, 1)
if not parts: return ''
head = parts[0].lower()
if head.startswith('/'): head = '/' + head[1:].split('@', 1)[0]
return head + (f" {parts[1].strip()}" if len(parts) > 1 and parts[1].strip() else '')
def _cancel_stream_task(ctx):
task = ctx.user_data.pop('stream_task', None)
if task and not task.done(): task.cancel()
async def _sync_commands(application):
await application.bot.set_my_commands([BotCommand(command, description) for command, description in TELEGRAM_MENU_COMMANDS])
async def _reply_command_text(message, text):
for segment in _markdown_safe_segments(text) or ["..."]:
try:
await message.reply_text(_to_markdown_v2(segment), parse_mode=ParseMode.MARKDOWN_V2)
except Exception as exc:
print(f"[TG command markdown fallback] {type(exc).__name__}: {exc}", flush=True)
await message.reply_text(segment)
def _review_command_body(cmd):
cmd = (cmd or "").strip()
if cmd == "/review":
return ""
if cmd.startswith("/review "):
return cmd[len("/review"):].strip()
return ""
async def _handle_review_command(update, ctx, cmd):
dq = Q.Queue()
prompt = handle_review_command(agent, _review_command_body(cmd), dq)
if not prompt:
try:
item = dq.get_nowait()
return await _reply_command_text(update.message, item.get("done", ""))
except Q.Empty:
return await _reply_command_text(update.message, "(review 无输出)")
_cancel_stream_task(ctx)
task_dq = agent.put_task(prompt, source="telegram")
task = asyncio.create_task(_stream(task_dq, update.message))
ctx.user_data['stream_task'] = task
async def handle_msg(update, ctx):
uid = update.effective_user.id
if ALLOWED and uid not in ALLOWED:
return await update.message.reply_text("no")
prompt = _build_text_prompt(update.message.text)
dq = agent.put_task(prompt, source="telegram")
task = asyncio.create_task(_stream(dq, update.message))
ctx.user_data['stream_task'] = task
async def handle_ask_callback(update, ctx):
query = update.callback_query
if query is None:
return
uid = update.effective_user.id if update.effective_user else None
if ALLOWED and uid not in ALLOWED:
return await query.answer("no", show_alert=True)
menu_id, action = _parse_ask_callback_data(query.data)
if not menu_id:
return await query.answer("菜单无效")
event = _normalize_ask_menu_event(_ask_menu_store.get(menu_id))
if event is None:
await query.answer("菜单已过期")
return await _clear_ask_reply_markup(query)
candidates = event["candidates"]
if event.get("multi") and action.startswith(f"{_ASK_TOGGLE_ACTION}:"):
try:
selected_idx = int(action.split(":", 1)[1])
if selected_idx < 0 or selected_idx >= len(candidates):
raise ValueError
except ValueError:
return await query.answer("菜单无效")
stored = _ask_menu_store.get(menu_id)
if not isinstance(stored, dict):
return await query.answer("菜单已过期")
selected = set(stored.get("selected", []))
if selected_idx in selected:
selected.remove(selected_idx)
else:
selected.add(selected_idx)
stored["selected"] = sorted(selected)
await query.answer()
return await query.edit_message_reply_markup(
reply_markup=_build_ask_user_markup(
menu_id,
candidates,
multi=True,
selected_indexes=stored["selected"],
)
)
if event.get("multi") and action == _ASK_MULTI_DONE_ACTION:
selected_indexes = event.get("selected") or []
if not selected_indexes:
return await query.answer(_ASK_MULTI_EMPTY_HINT, show_alert=True)
selected = "; ".join(candidates[idx] for idx in selected_indexes)
_ask_menu_store.pop(menu_id, None)
await query.answer()
await _edit_ask_user_result(query, event, selected=selected)
if query.message is None:
return
dq = agent.put_task(_build_text_prompt(selected), source="telegram")
task = asyncio.create_task(_stream(dq, query.message))
ctx.user_data['stream_task'] = task
return
if action == _ASK_CANCEL_ACTION:
_ask_menu_store.pop(menu_id, None)
await query.answer()
await _edit_ask_user_result(query, event, cancelled=True)
if query.message is not None:
await query.message.reply_text(_ASK_CANCEL_PROMPT)
return
try:
selected = candidates[int(action)]
except (ValueError, IndexError):
return await query.answer("菜单无效")
_ask_menu_store.pop(menu_id, None)
await query.answer()
await _edit_ask_user_result(query, event, selected=selected)
if query.message is None:
return
dq = agent.put_task(_build_text_prompt(selected), source="telegram")
task = asyncio.create_task(_stream(dq, query.message))
ctx.user_data['stream_task'] = task
async def _send_llm_menu(message):
llms = agent.list_llms()
if not llms:
return await message.reply_text("没有可用模型。")
menu_id = uuid.uuid4().hex[:16]
_llm_menu_store[menu_id] = [idx for idx, _, _ in llms]
lines = [f"{'→' if cur else ' '} [{idx}] {name}" for idx, name, cur in llms]
try:
await message.reply_text(
_LLM_MENU_PROMPT,
reply_markup=_build_llm_markup(menu_id, llms),
)
except Exception as exc:
_llm_menu_store.pop(menu_id, None)
print(f"[TG llm menu error] {type(exc).__name__}: {exc}", flush=True)
await message.reply_text("LLMs:\n" + "\n".join(lines))
async def handle_llm_callback(update, ctx):
query = update.callback_query
if query is None:
return
uid = update.effective_user.id if update.effective_user else None
if ALLOWED and uid not in ALLOWED:
return await query.answer("no", show_alert=True)
menu_id, action = _parse_menu_callback_data(query.data, _LLM_CALLBACK_PREFIX)
if not menu_id:
return await query.answer("菜单无效")
valid_indexes = _llm_menu_store.get(menu_id)
if valid_indexes is None:
await query.answer("菜单已过期")
return await _clear_ask_reply_markup(query)
try:
selected_idx = int(action)
except (TypeError, ValueError):
return await query.answer("菜单无效")
if selected_idx not in valid_indexes:
return await query.answer("菜单已过期", show_alert=True)
try:
agent.next_llm(selected_idx)
selected_name = agent.get_llm_name()
except Exception as exc:
return await query.answer(f"切换失败: {exc}", show_alert=True)
_llm_menu_store.pop(menu_id, None)
await query.answer(f"已切换到 [{selected_idx}] {selected_name}")
await query.edit_message_text(f"✅ 已切换到 [{selected_idx}] {selected_name}")
async def cmd_abort(update, ctx):
_cancel_stream_task(ctx)
agent.abort()
await update.message.reply_text("⏹️ 正在停止...")
async def cmd_llm(update, ctx):
args = (update.message.text or '').split()
if len(args) > 1:
try:
n = int(args[1])
agent.next_llm(n)
await update.message.reply_text(f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}")
except (ValueError, IndexError):
await update.message.reply_text(f"用法: /llm <0-{len(agent.list_llms())-1}>")
else:
await _send_llm_menu(update.message)
async def handle_photo(update, ctx):
uid = update.effective_user.id
if ALLOWED and uid not in ALLOWED: return await update.message.reply_text("no")
if update.message.photo:
photo = update.message.photo[-1]
file = await photo.get_file()
fpath = f"tg_{photo.file_unique_id}.jpg"
kind = "图片"
elif update.message.document:
doc = update.message.document
file = await doc.get_file()
ext = os.path.splitext(doc.file_name or '')[1] or ''
fpath = f"tg_{doc.file_unique_id}{ext}"
kind = "文件"
else: return
await file.download_to_drive(os.path.join(_TEMP_DIR, fpath))
caption = update.message.caption
prompt = f"[TIPS] 收到{kind}temp/{fpath}\n{caption}" if caption else f"[TIPS] 收到{kind}temp/{fpath},请等待下一步指令"
dq = agent.put_task(prompt, source="telegram")
task = asyncio.create_task(_stream(dq, update.message))
ctx.user_data['stream_task'] = task
async def handle_command(update, ctx):
uid = update.effective_user.id
if ALLOWED and uid not in ALLOWED:
return await update.message.reply_text("no")
cmd = _normalized_command(update.message.text)
op = cmd.split()[0] if cmd else ''
if op == '/help': return await update.message.reply_text(HELP_TEXT)
if op == '/status':
llm = agent.get_llm_name() if agent.llmclient else '未配置'
return await update.message.reply_text(f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}")
if op == '/stop': return await cmd_abort(update, ctx)
if op == '/llm': return await cmd_llm(update, ctx)
if op == '/btw':
answer = await asyncio.to_thread(handle_btw_frontend_command, agent, cmd)
return await _reply_command_text(update.message, answer)
if op == '/review':
return await _handle_review_command(update, ctx, cmd)
if op == '/new':
_cancel_stream_task(ctx)
return await update.message.reply_text(reset_conversation(agent))
if op == '/restore':
_cancel_stream_task(ctx)
try:
restored_info, err = format_restore()
if err:
return await update.message.reply_text(err)
restored, fname, count = restored_info
agent.abort()
agent.history.extend(restored)
return await update.message.reply_text(f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)")
except Exception as e:
return await update.message.reply_text(f"❌ 恢复失败: {e}")
if op == '/continue':
if cmd != '/continue': _cancel_stream_task(ctx)
return await update.message.reply_text(handle_frontend_command(agent, cmd))
return await update.message.reply_text(HELP_TEXT)
if __name__ == '__main__':
_LOCK_SOCK = ensure_single_instance(19527, "Telegram")
if not ALLOWED:
print('[Telegram] ERROR: tg_allowed_users in mykey.py is empty or missing. Set it to avoid unauthorized access.')
sys.exit(1)
require_runtime(agent, "Telegram", tg_bot_token=mykeys.get("tg_bot_token"))
redirect_log(__file__, "tgapp.log", "Telegram", ALLOWED)
_register_ask_user_hook()
threading.Thread(target=agent.run, daemon=True).start()
proxy = mykeys.get('proxy')
if proxy:
print('proxy:', proxy)
else:
print('proxy: <disabled>')
async def _error_handler(update, context: ContextTypes.DEFAULT_TYPE):
print(f"[{time.strftime('%m-%d %H:%M')}] TG error: {context.error}", flush=True)
while True:
try:
print(f"TG bot starting... {time.strftime('%m-%d %H:%M')}")
# Recreate request and app objects on each restart to avoid stale connections
request_kwargs = dict(read_timeout=30, write_timeout=30, connect_timeout=30, pool_timeout=30)
if proxy:
request_kwargs['proxy'] = proxy
request = HTTPXRequest(**request_kwargs)
app = (ApplicationBuilder().token(mykeys['tg_bot_token'])
.request(request).get_updates_request(request).post_init(_sync_commands).build())
app.add_handler(CallbackQueryHandler(handle_ask_callback, pattern=r"^ask:"))
app.add_handler(CallbackQueryHandler(handle_llm_callback, pattern=r"^llm:"))
app.add_handler(MessageHandler(filters.COMMAND, handle_command))
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
app.add_handler(MessageHandler(filters.Document.ALL, handle_photo))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_msg))
app.add_error_handler(_error_handler)
app.run_polling(drop_pending_updates=True, poll_interval=1.0, timeout=30)
except Exception as e:
print(f"[{time.strftime('%m-%d %H:%M')}] polling crashed: {e}", flush=True)
time.sleep(10)
asyncio.set_event_loop(asyncio.new_event_loop())