Download core/telegram.py from LoveLogicAI/kanbot-backend: direct link, hf CLI and curl.
- Browser
- Download file 6.6 kB
-
https://huggingface.co/spaces/LoveLogicAI/kanbot-backend/resolve/main/core/telegram.py
- Command line
-
hf download hf://spaces/LoveLogicAI/kanbot-backend/core/telegram.py
-
curl -L -o telegram.py https://huggingface.co/spaces/LoveLogicAI/kanbot-backend/resolve/main/core/telegram.py
6.6 kB
| """ | |
| 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 <reason> block with reason | |
| /block CARD-x <reason> block | |
| /card CARD-x show detail | |
| /col <column> 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()) | |