Wire the existing /review handler into tgapp so Telegram users can invoke in-session code review, including help responses and streamed agent execution.
1139 lines
44 KiB
Python
1139 lines
44 KiB
Python
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())
|