kanbot-backend / core /telegram.py
LoveLogicAI's picture
Upload folder using huggingface_hub
587a933 verified
Raw History Blame Contribute Delete
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())