""" Telegram face — the phone-in-your-pocket view of the board. Two directions: OUT: relay_human_request() pushes a card's HUMAN-NEEDED prompt to your chat. IN : run_bot() long-polls for commands and drives the board via the same MCP operations everything else uses. Same nine verbs, just from your thumb. Typed commands (matching the CLI): /ready CARD-x promote to ready /approve CARD-x approve a dry-run proposal -> requeue /reject CARD-x block with reason /block CARD-x block /card CARD-x show detail /col list a column Inline buttons: every human-needed relay carries [✅ Approve] [⛔ Reject] [🔍 Details] buttons — one tap drives the board, no typing. Taps arrive as callback_query updates; the handler answers the query (stops the spinner) and runs the same board operations as the typed commands. If TELEGRAM_TOKEN / TELEGRAM_CHAT_ID are unset, relay is a no-op (prints to stdout, including which buttons would show) so the rest of the stack runs headless. """ from __future__ import annotations import os import httpx from .contract import Card TOKEN = os.environ.get("TELEGRAM_TOKEN", "") CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") API = f"https://api.telegram.org/bot{TOKEN}" async def _send(text: str, reply_markup: dict | None = None) -> None: if not (TOKEN and CHAT_ID): tail = " [buttons: " + " / ".join( b["text"] for row in reply_markup["inline_keyboard"] for b in row ) + "]" if reply_markup else "" print(f"[telegram:noop] {text}{tail}") return payload: dict = {"chat_id": CHAT_ID, "text": text} if reply_markup: payload["reply_markup"] = reply_markup async with httpx.AsyncClient(timeout=20) as c: await c.post(f"{API}/sendMessage", json=payload) def _approval_keyboard(card_id: str) -> dict: """Inline keyboard: one tap to approve/reject/inspect a human-needed card. callback_data is compact (Telegram caps it at 64 bytes): 'a|CARD-x' etc.""" return {"inline_keyboard": [[ {"text": "✅ Approve", "callback_data": f"a|{card_id}"}, {"text": "⛔ Reject", "callback_data": f"r|{card_id}"}, {"text": "🔍 Details", "callback_data": f"d|{card_id}"}, ]]} async def relay_human_request(card: Card) -> None: q = next((e.content for e in reversed(card.thread) if e.content.startswith("HUMAN NEEDED")), card.title) await _send(f"🔔 {card.id} — {card.title}\n{q}", reply_markup=_approval_keyboard(card.id)) # --------------------------------------------------------------------------- # # inbound bot loop # --------------------------------------------------------------------------- # async def _handle_command(text: str) -> str: from .mcp_server import block_card, get_card, list_column, move_card parts = text.strip().split(maxsplit=2) cmd = parts[0].lstrip("/").lower() arg = parts[1] if len(parts) > 1 else "" rest = parts[2] if len(parts) > 2 else "" if cmd == "ready": return await move_card(arg, "ready", actor="human:telegram", reason="phoned in") if cmd == "approve": from .workers import approve_and_run await approve_and_run(arg) return f"approved {arg}" if cmd == "reject": return await block_card(arg, rest or "rejected by human", actor="human:telegram") if cmd == "block": return await block_card(arg, rest or "blocked by human", actor="human:telegram") if cmd == "card": return await get_card(arg) if cmd == "col": return await list_column(arg) return "commands: /ready /approve /reject /block /card /col" async def _handle_callback(data: str) -> str: """Handle an inline-button tap. data is 'a|CARD-x' / 'r|CARD-x' / 'd|CARD-x'.""" from .mcp_server import block_card, get_card from .workers import approve_and_run action, _, card_id = data.partition("|") if not card_id: return "malformed button" if action == "a": await approve_and_run(card_id) return f"✅ approved {card_id} — requeued to ready" if action == "r": await block_card(card_id, "rejected via Telegram button", actor="human:telegram") return f"⛔ rejected {card_id} — kept in blocked" if action == "d": import json d = json.loads(await get_card(card_id)) if "error" in d: return d["error"] proposals = [e for e in d.get("thread", []) if e["kind"] == "tool_call"] head = f"🔍 {d['id']} [{d['column']}] — {d['title']}" body = "\n".join(f"• {e['content'][:300]}" for e in proposals[-3:]) \ or "(no tool-call proposals yet)" return f"{head}\n{body}" return "unknown action" async def _answer_callback(client: httpx.AsyncClient, cbid: str, text: str) -> None: """Stop the button's spinner + show a toast. No-op without a token.""" if not TOKEN: return await client.post(f"{API}/answerCallbackQuery", json={"callback_query_id": cbid, "text": text[:190]}) async def run_bot() -> None: if not TOKEN: print("[telegram] TELEGRAM_TOKEN unset — bot disabled") return offset = 0 print("[telegram] bot polling (typed commands + inline buttons)") async with httpx.AsyncClient(timeout=40) as c: while True: r = await c.get(f"{API}/getUpdates", params={"offset": offset, "timeout": 30}) for upd in r.json().get("result", []): offset = upd["update_id"] + 1 # --- inline button tap ------------------------------------- # cb = upd.get("callback_query") if cb: if str(cb.get("message", {}).get("chat", {}).get("id")) != str(CHAT_ID): continue # only obey your chat reply = await _handle_callback(cb.get("data", "")) await _answer_callback(c, cb["id"], reply) await _send(reply[:3500]) continue # --- typed command ----------------------------------------- # msg = upd.get("message", {}) if str(msg.get("chat", {}).get("id")) != str(CHAT_ID): continue # only obey your chat reply = await _handle_command(msg.get("text", "")) await _send(reply[:3500]) if __name__ == "__main__": import asyncio asyncio.run(run_bot())