forked from retoor/devplacepy
Update
This commit is contained in:
@@ -38,11 +38,19 @@ TOO_MANY = "Too many attempts. Wait a few minutes, request a new code, and try a
|
||||
|
||||
|
||||
class TelegramConnection:
|
||||
def __init__(self, service: Any, session: Any, chat_id: int, message_id: int | None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
service: Any,
|
||||
session: Any,
|
||||
chat_id: int,
|
||||
message_id: int | None,
|
||||
bridge: "TelegramBridge | None" = None,
|
||||
) -> None:
|
||||
self._service = service
|
||||
self._session = session
|
||||
self._chat_id = chat_id
|
||||
self._message_id = message_id
|
||||
self._bridge = bridge
|
||||
self.done = asyncio.Event()
|
||||
self._finalized = False
|
||||
self._last_status = ""
|
||||
@@ -57,6 +65,8 @@ class TelegramConnection:
|
||||
await self._finalize(html.escape("Error: " + str(payload.get("text", ""))))
|
||||
elif kind in ("status", "trace"):
|
||||
await self._progress(payload)
|
||||
elif kind == "interaction":
|
||||
await self._handle_interaction(payload)
|
||||
elif kind in ("avatar", "client"):
|
||||
query_id = payload.get("id")
|
||||
if query_id:
|
||||
@@ -64,6 +74,45 @@ class TelegramConnection:
|
||||
query_id, {"error": "No browser is attached (Telegram session)."}
|
||||
)
|
||||
|
||||
async def _handle_interaction(self, payload: dict[str, Any]) -> None:
|
||||
request_id = str(payload.get("id") or "")
|
||||
args = payload.get("args") if isinstance(payload.get("args"), dict) else payload
|
||||
interaction_id = str(
|
||||
args.get("interaction_id") or payload.get("interaction_id") or ""
|
||||
)
|
||||
plain = str(args.get("plain") or args.get("markdown") or "")
|
||||
widgets = args.get("widgets") if isinstance(args.get("widgets"), list) else []
|
||||
markup = _telegram_keyboard(interaction_id, widgets, args)
|
||||
text = html.escape(plain) if plain else "Please choose:"
|
||||
if markup is not None:
|
||||
await self._service.send(
|
||||
self._chat_id, f"<pre>{text}</pre>", reply_markup=markup
|
||||
)
|
||||
else:
|
||||
await self._service.send(self._chat_id, f"<pre>{text}</pre>")
|
||||
if self._bridge is None:
|
||||
self._session.resolve_interaction(
|
||||
request_id,
|
||||
{
|
||||
"status": "error",
|
||||
"interaction_id": interaction_id,
|
||||
"error": "Telegram bridge unavailable for interaction reply.",
|
||||
"values": {},
|
||||
},
|
||||
)
|
||||
return
|
||||
try:
|
||||
result = await self._bridge.wait_interaction_reply(
|
||||
self._chat_id, interaction_id, args
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
result = {
|
||||
"status": "timeout",
|
||||
"interaction_id": interaction_id,
|
||||
"values": {},
|
||||
}
|
||||
self._session.resolve_interaction(request_id, result)
|
||||
|
||||
async def _typing_loop(self) -> None:
|
||||
try:
|
||||
while not self.done.is_set():
|
||||
@@ -122,6 +171,7 @@ class TelegramBridge:
|
||||
self._semaphore = asyncio.Semaphore(max(1, max_concurrent))
|
||||
self._chat_locks: dict[int, asyncio.Lock] = {}
|
||||
self._attempts: dict[int, list[float]] = {}
|
||||
self._pending_interactions: dict[int, dict[str, Any]] = {}
|
||||
|
||||
def _chat_lock(self, chat_id: int) -> asyncio.Lock:
|
||||
lock = self._chat_locks.get(chat_id)
|
||||
@@ -133,11 +183,81 @@ class TelegramBridge:
|
||||
self._chat_locks[chat_id] = lock
|
||||
return lock
|
||||
|
||||
async def wait_interaction_reply(
|
||||
self, chat_id: int, interaction_id: str, args: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
loop = asyncio.get_event_loop()
|
||||
future: asyncio.Future = loop.create_future()
|
||||
timeout = float(args.get("timeout_sec") or 0) or 300.0
|
||||
self._pending_interactions[chat_id] = {
|
||||
"interaction_id": interaction_id,
|
||||
"future": future,
|
||||
"args": args,
|
||||
}
|
||||
try:
|
||||
return await asyncio.wait_for(future, timeout=timeout)
|
||||
finally:
|
||||
current = self._pending_interactions.get(chat_id)
|
||||
if current and current.get("future") is future:
|
||||
self._pending_interactions.pop(chat_id, None)
|
||||
|
||||
def _resolve_pending(
|
||||
self, chat_id: int, result: dict[str, Any]
|
||||
) -> bool:
|
||||
pending = self._pending_interactions.get(chat_id)
|
||||
if pending is None:
|
||||
return False
|
||||
future = pending.get("future")
|
||||
if future is not None and not future.done():
|
||||
future.set_result(result)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def handle_inbound(self, event: dict[str, Any]) -> None:
|
||||
kind = event.get("type") or "message"
|
||||
chat_id = int(event["chat_id"])
|
||||
from_id = int(event["from_id"])
|
||||
if kind == "callback":
|
||||
await self._handle_callback(event)
|
||||
return
|
||||
text = str(event.get("text", "")).strip()
|
||||
images = [img for img in event.get("images", []) if img]
|
||||
if chat_id in self._pending_interactions and text:
|
||||
from devplacepy.services.devii.interaction.parse import parse_plain_reply
|
||||
from devplacepy.services.devii.interaction.schema import InteractionRequest
|
||||
|
||||
pending = self._pending_interactions[chat_id]
|
||||
try:
|
||||
request = InteractionRequest.model_validate(
|
||||
(pending.get("args") or {}).get("request")
|
||||
or {
|
||||
"title": "?",
|
||||
"widgets": (pending.get("args") or {}).get("widgets") or [],
|
||||
}
|
||||
)
|
||||
status, values, error = parse_plain_reply(request, text)
|
||||
self._resolve_pending(
|
||||
chat_id,
|
||||
{
|
||||
"status": status,
|
||||
"interaction_id": pending.get("interaction_id"),
|
||||
"values": values,
|
||||
"error": error,
|
||||
"meta": {"adapter": "telegram", "degraded": True},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
self._resolve_pending(
|
||||
chat_id,
|
||||
{
|
||||
"status": "error",
|
||||
"interaction_id": pending.get("interaction_id"),
|
||||
"values": {},
|
||||
"error": "Could not parse reply.",
|
||||
"meta": {"adapter": "telegram", "degraded": True},
|
||||
},
|
||||
)
|
||||
return
|
||||
link = store.user_for_chat(chat_id)
|
||||
if link is None:
|
||||
await self._handle_unpaired(chat_id, from_id, text)
|
||||
@@ -154,6 +274,55 @@ class TelegramBridge:
|
||||
async with self._semaphore:
|
||||
await self._run_turn(chat_id, user, text, images)
|
||||
|
||||
async def _handle_callback(self, event: dict[str, Any]) -> None:
|
||||
chat_id = int(event["chat_id"])
|
||||
data = str(event.get("data") or "")
|
||||
callback_id = str(event.get("callback_query_id") or "")
|
||||
if callback_id:
|
||||
try:
|
||||
await self._service.answer_callback(callback_id)
|
||||
except Exception:
|
||||
pass
|
||||
pending = self._pending_interactions.get(chat_id)
|
||||
if pending is None:
|
||||
return
|
||||
interaction_id = str(pending.get("interaction_id") or "")
|
||||
parsed = _parse_callback_data(data)
|
||||
if not parsed:
|
||||
return
|
||||
if parsed.get("i") and parsed["i"] not in interaction_id:
|
||||
if not interaction_id.endswith(parsed["i"]) and parsed["i"] not in interaction_id:
|
||||
pass
|
||||
status = parsed.get("s") or "submitted"
|
||||
if status == "cancel":
|
||||
self._resolve_pending(
|
||||
chat_id,
|
||||
{
|
||||
"status": "cancelled",
|
||||
"interaction_id": interaction_id,
|
||||
"values": {},
|
||||
"meta": {"adapter": "telegram", "degraded": True},
|
||||
},
|
||||
)
|
||||
return
|
||||
name = parsed.get("k")
|
||||
value = parsed.get("v")
|
||||
values: dict[str, Any] = {}
|
||||
if name is not None:
|
||||
if value in ("true", "false"):
|
||||
values[name] = value == "true"
|
||||
else:
|
||||
values[name] = value
|
||||
self._resolve_pending(
|
||||
chat_id,
|
||||
{
|
||||
"status": "submitted",
|
||||
"interaction_id": interaction_id,
|
||||
"values": values,
|
||||
"meta": {"adapter": "telegram", "degraded": True},
|
||||
},
|
||||
)
|
||||
|
||||
async def _handle_unpaired(self, chat_id: int, from_id: int, text: str) -> None:
|
||||
if text.isdigit() and len(text) == 4:
|
||||
if self._too_many_attempts(chat_id):
|
||||
@@ -247,9 +416,12 @@ class TelegramBridge:
|
||||
)
|
||||
session.set_timezone(user.get("timezone") or "")
|
||||
placeholder_id = await self._service.send(chat_id, THINKING_PLACEHOLDER)
|
||||
connection = TelegramConnection(self._service, session, chat_id, placeholder_id)
|
||||
connection = TelegramConnection(
|
||||
self._service, session, chat_id, placeholder_id, bridge=self
|
||||
)
|
||||
session.interaction_broker.bind(present_telegram=None)
|
||||
session.attach(connection)
|
||||
content, audit_text = self._build_content(text, images)
|
||||
content, audit_text = _build_content(text, images)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
session.spawn_turn(content, audit_text=audit_text)
|
||||
@@ -265,13 +437,74 @@ class TelegramBridge:
|
||||
session.detach(connection)
|
||||
self._service.record_latency(time.monotonic() - started)
|
||||
|
||||
@staticmethod
|
||||
def _build_content(text: str, images: list[str]) -> tuple[Any, str]:
|
||||
if not images:
|
||||
return text, text
|
||||
prompt = text or "Look at the attached image and respond."
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
for uri in images:
|
||||
content.append({"type": "image_url", "image_url": {"url": uri}})
|
||||
audit_text = f"{text} [{len(images)} image(s)]".strip()
|
||||
return content, audit_text
|
||||
|
||||
def _telegram_keyboard(
|
||||
interaction_id: str, widgets: list[Any], args: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
short = interaction_id.replace("ix-", "")[:8]
|
||||
rows: list[list[dict[str, str]]] = []
|
||||
inputs = [
|
||||
w
|
||||
for w in widgets
|
||||
if isinstance(w, dict) and w.get("type") not in ("//", "group", None)
|
||||
]
|
||||
if len(inputs) == 1 and inputs[0].get("type") == "confirm":
|
||||
name = str(inputs[0].get("name") or "confirm")
|
||||
submit = str(args.get("submit_label") or "Confirm")[:20]
|
||||
cancel = str(args.get("cancel_label") or "Cancel")[:20]
|
||||
rows.append(
|
||||
[
|
||||
{
|
||||
"text": submit,
|
||||
"callback_data": f"i={short};k={name};v=true"[:64],
|
||||
},
|
||||
{
|
||||
"text": cancel,
|
||||
"callback_data": f"i={short};s=cancel"[:64],
|
||||
},
|
||||
]
|
||||
)
|
||||
return {"inline_keyboard": rows}
|
||||
if len(inputs) == 1 and inputs[0].get("type") in ("choice", "select"):
|
||||
name = str(inputs[0].get("name") or "choice")
|
||||
options = inputs[0].get("options") or []
|
||||
for option in options[:12]:
|
||||
if not isinstance(option, dict):
|
||||
continue
|
||||
value = str(option.get("value") or "")[:20]
|
||||
label = str(option.get("label") or value)[:40]
|
||||
rows.append(
|
||||
[
|
||||
{
|
||||
"text": label,
|
||||
"callback_data": f"i={short};k={name};v={value}"[:64],
|
||||
}
|
||||
]
|
||||
)
|
||||
if args.get("cancelable", True):
|
||||
rows.append(
|
||||
[{"text": "Cancel", "callback_data": f"i={short};s=cancel"[:64]}]
|
||||
)
|
||||
return {"inline_keyboard": rows} if rows else None
|
||||
return None
|
||||
|
||||
|
||||
def _parse_callback_data(data: str) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for part in str(data or "").split(";"):
|
||||
if "=" not in part:
|
||||
continue
|
||||
key, value = part.split("=", 1)
|
||||
out[key.strip()] = value.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _build_content(text: str, images: list[str]) -> tuple[Any, str]:
|
||||
if not images:
|
||||
return text, text
|
||||
prompt = text or "Look at the attached image and respond."
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
for uri in images:
|
||||
content.append({"type": "image_url", "image_url": {"url": uri}})
|
||||
audit_text = f"{text} [{len(images)} image(s)]".strip()
|
||||
return content, audit_text
|
||||
|
||||
Reference in New Issue
Block a user