Skip to content

Commit d756d4a

Browse files
authored
Merge pull request #82 from makespacemadrid/codex/update-telegram-bot-for-natural-language-support
Add optional Ocabra-compatible Telegram assistant, bounded executor, deduplication, and Telegram client improvements
2 parents 08f6e9e + 74160f7 commit d756d4a

18 files changed

Lines changed: 1199 additions & 7 deletions

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ A Flask + SQLite application for managing budget proposals in a hackerspace.
1919
- **Proposals**: weighted vote thresholds, creator auto-vote, edit/delete by owner/admin, approval undo, purchase tracking.
2020
- **Polls**: 2..12 options, transparent results, close/reopen/delete, web/Telegram vote modes.
2121
- **Group purchases**: shared orders with individually priced options, proportional shipping/tax costs, per-member quantities, deadlines, payment tracking, fulfillment states, and Telegram lifecycle notifications.
22-
- **Telegram integration**: `/link`, `/vote`, `/pvote`, inline poll/proposal callbacks, webhook processing.
22+
- **Telegram integration**: `/link`, `/vote`, `/pvote`, inline callbacks, and an optional Ocabra-compatible natural-language assistant with MCP tools, DB-backed access, confirmed admin mutations, retry deduplication, and bounded background work.
2323
- **Budget lifecycle**: approval when threshold+budget are met, over-budget queue with auto-approval later.
2424
- **Timezone-aware UI**: all timestamps are rendered in configured timezone.
2525

@@ -48,6 +48,13 @@ See [`docs/QUICKSTART.md`](docs/QUICKSTART.md) for Docker/local setup, bootstrap
4848
- Main docs index: [`docs/INDEX.md`](docs/INDEX.md)
4949
- Direct links: [`docs/QUICKSTART.md`](docs/QUICKSTART.md), [`docs/APIDOC.md`](docs/APIDOC.md), [`docs/SPEC.md`](docs/SPEC.md), [`docs/TESTING.md`](docs/TESTING.md)
5050

51+
## Acknowledgements
52+
53+
The natural-language Telegram assistant was inspired by Luis Rivera's
54+
[`ocabra_telegram`](https://github.com/luisriverag/ocabra_telegram) project. ManaVote
55+
adapts its OpenAI-compatible Telegram conversation approach to the existing webhook,
56+
database-backed member access controls, and MCP tools in this application.
57+
5158
## Frontend development
5259

5360
The shared application shell is implemented in React and built with Vite, while Flask
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Small bounded wrapper around :mod:`concurrent.futures` executors."""
2+
3+
from __future__ import annotations
4+
5+
import threading
6+
from concurrent.futures import Future, ThreadPoolExecutor
7+
from typing import Callable
8+
9+
10+
class BoundedExecutor:
11+
"""Reject jobs instead of allowing an unbounded in-memory work queue."""
12+
13+
def __init__(self, *, max_workers: int, max_pending: int, thread_name_prefix: str):
14+
if max_workers < 1 or max_pending < 0:
15+
raise ValueError("max_workers must be positive and max_pending cannot be negative")
16+
self._executor = ThreadPoolExecutor(
17+
max_workers=max_workers,
18+
thread_name_prefix=thread_name_prefix,
19+
)
20+
self._capacity = threading.BoundedSemaphore(max_workers + max_pending)
21+
22+
def submit(self, function: Callable, *args, **kwargs) -> Future | None:
23+
"""Submit immediately, or return ``None`` when all slots are occupied."""
24+
if not self._capacity.acquire(blocking=False):
25+
return None
26+
try:
27+
future = self._executor.submit(function, *args, **kwargs)
28+
except RuntimeError:
29+
self._capacity.release()
30+
raise
31+
future.add_done_callback(lambda _future: self._capacity.release())
32+
return future
33+
34+
def shutdown(self, *, wait: bool = True) -> None:
35+
self._executor.shutdown(wait=wait)

app/integrations/telegram_agent.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""Natural-language Telegram agent backed by an OpenAI-compatible model and MCP.
2+
3+
The Telegram conversation pattern is inspired by Luis Rivera's ocabra_telegram:
4+
https://github.com/luisriverag/ocabra_telegram
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import os
11+
import threading
12+
import time
13+
from collections import defaultdict, deque
14+
from dataclasses import dataclass, field
15+
from typing import Any
16+
17+
import requests
18+
19+
from app import mcp_server
20+
21+
22+
READ_ONLY_TOOLS = {
23+
"list_proposals",
24+
"current_budget",
25+
"get_voting_settings",
26+
}
27+
MUTATING_TOOLS = {"create_member", "create_proposal", "create_poll", "update_voting_settings"}
28+
MAX_TOOL_ROUNDS = 4
29+
ConversationKey = tuple[int, int]
30+
_history: dict[ConversationKey, deque[dict[str, Any]]] = defaultdict(lambda: deque(maxlen=12))
31+
_pending_actions: dict[ConversationKey, "PendingAction"] = {}
32+
_state_lock = threading.RLock()
33+
34+
35+
@dataclass(frozen=True)
36+
class PendingAction:
37+
tool_name: str
38+
arguments: dict[str, Any]
39+
created_at: float = field(default_factory=time.monotonic)
40+
41+
42+
def is_configured() -> bool:
43+
return bool(os.getenv("OCABRA_CHAT_URL", "").strip() and mcp_server.MCP_API_KEY)
44+
45+
46+
def _headers() -> dict[str, str]:
47+
headers = {"Content-Type": "application/json"}
48+
api_key = os.getenv("OCABRA_API_KEY", "").strip()
49+
if api_key:
50+
headers["Authorization"] = f"Bearer {api_key}"
51+
return headers
52+
53+
54+
def _openai_tools(*, is_admin: bool) -> list[dict[str, Any]]:
55+
definitions = mcp_server._tool_definitions()
56+
if not is_admin:
57+
definitions = [item for item in definitions if item["name"] in READ_ONLY_TOOLS]
58+
return [
59+
{
60+
"type": "function",
61+
"function": {
62+
"name": item["name"],
63+
"description": item["description"],
64+
"parameters": item["inputSchema"],
65+
},
66+
}
67+
for item in definitions
68+
]
69+
70+
71+
def _call_mcp(tool_name: str, arguments: dict[str, Any], *, is_admin: bool) -> str:
72+
allowed = {item["function"]["name"] for item in _openai_tools(is_admin=is_admin)}
73+
if tool_name not in allowed:
74+
return json.dumps({"error": "This Telegram account may not use that tool."})
75+
response = mcp_server.handle_request(
76+
{
77+
"jsonrpc": "2.0",
78+
"id": "telegram-agent",
79+
"method": "tools/call",
80+
"params": {
81+
"name": tool_name,
82+
"arguments": arguments,
83+
"api_key": mcp_server.MCP_API_KEY,
84+
},
85+
}
86+
)
87+
if not response:
88+
return json.dumps({"error": "MCP returned no response."})
89+
if "error" in response:
90+
return json.dumps({"error": response["error"]["message"]})
91+
content = response.get("result", {}).get("content", [])
92+
return content[0].get("text", "{}") if content else "{}"
93+
94+
95+
def _conversation_key(chat_id: int, telegram_user_id: int | None) -> ConversationKey:
96+
# Private chats normally use the same value for both IDs. Including the user
97+
# ID prevents one member in a group chat from seeing another member's context.
98+
return int(chat_id), int(telegram_user_id if telegram_user_id is not None else chat_id)
99+
100+
101+
def _confirmation_text(action: PendingAction) -> str:
102+
return (
103+
f"⚠️ Please confirm MCP action {action.tool_name} with /confirm, "
104+
"or discard it with /cancel.\n"
105+
f"Arguments: {json.dumps(action.arguments, ensure_ascii=False, sort_keys=True)}"
106+
)
107+
108+
109+
def _action_result_text(action: PendingAction, result: str) -> str:
110+
"""Turn MCP JSON into a compact, Telegram-friendly completion message."""
111+
try:
112+
payload = json.loads(result)
113+
except (TypeError, json.JSONDecodeError):
114+
payload = result
115+
if isinstance(payload, dict) and payload.get("error"):
116+
return f"❌ {action.tool_name} failed: {payload['error']}"
117+
if isinstance(payload, dict):
118+
details = "\n".join(
119+
f"{str(key).replace('_', ' ').capitalize()}: "
120+
f"{json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value}"
121+
for key, value in payload.items()
122+
)
123+
else:
124+
details = str(payload)
125+
suffix = f"\n{details}" if details and details != "{}" else ""
126+
return f"✅ {action.tool_name} completed.{suffix}"
127+
128+
129+
def answer(
130+
chat_id: int,
131+
text: str,
132+
*,
133+
telegram_user_id: int | None = None,
134+
is_admin: bool = False,
135+
) -> str:
136+
"""Answer natural language and let the model operate ManaVote through MCP tools."""
137+
url = os.getenv("OCABRA_CHAT_URL", "").strip()
138+
if not url:
139+
raise RuntimeError("Natural-language Telegram support is not configured.")
140+
141+
key = _conversation_key(chat_id, telegram_user_id)
142+
normalized_text = text.strip().lower()
143+
with _state_lock:
144+
pending = _pending_actions.get(key)
145+
if normalized_text in {"/cancel", "cancel"}:
146+
with _state_lock:
147+
removed = _pending_actions.pop(key, None)
148+
return "✅ Pending action cancelled." if removed else "There is no pending action to cancel."
149+
if normalized_text in {"/confirm", "confirm"}:
150+
if not pending:
151+
return "There is no pending action to confirm."
152+
confirmation_ttl = float(os.getenv("TELEGRAM_CONFIRM_TTL_SECONDS", "300"))
153+
if time.monotonic() - pending.created_at > confirmation_ttl:
154+
with _state_lock:
155+
_pending_actions.pop(key, None)
156+
return "⌛ That pending action expired. Please request it again."
157+
if not is_admin:
158+
with _state_lock:
159+
_pending_actions.pop(key, None)
160+
return "❌ Only a linked administrator can confirm that action."
161+
result = _call_mcp(pending.tool_name, pending.arguments, is_admin=True)
162+
with _state_lock:
163+
_pending_actions.pop(key, None)
164+
return _action_result_text(pending, result)
165+
166+
with _state_lock:
167+
history_snapshot = list(_history[key])
168+
messages: list[dict[str, Any]] = [
169+
{
170+
"role": "system",
171+
"content": os.getenv(
172+
"TELEGRAM_AGENT_SYSTEM_PROMPT",
173+
"You are the ManaVote Telegram assistant. Use the supplied tools for ManaVote facts and actions. "
174+
"Never invent tool results. If a tool says confirmation_required, repeat its confirmation message "
175+
"and do not claim the action succeeded. Reply concisely in the user's language using plain text "
176+
"that reads well in Telegram.",
177+
),
178+
},
179+
*history_snapshot,
180+
{"role": "user", "content": text},
181+
]
182+
tools = _openai_tools(is_admin=is_admin)
183+
184+
for _ in range(MAX_TOOL_ROUNDS):
185+
response = requests.post(
186+
url,
187+
headers=_headers(),
188+
json={
189+
"model": os.getenv("OCABRA_MODEL", "ocabra"),
190+
"messages": messages,
191+
"tools": tools,
192+
"tool_choice": "auto",
193+
"temperature": 0.2,
194+
"stream": False,
195+
},
196+
timeout=float(os.getenv("OCABRA_TIMEOUT_SECONDS", "60")),
197+
)
198+
response.raise_for_status()
199+
assistant = response.json()["choices"][0]["message"]
200+
tool_calls = assistant.get("tool_calls") or []
201+
if not tool_calls:
202+
reply = (assistant.get("content") or "I couldn't produce a response.").strip()
203+
with _state_lock:
204+
_history[key].extend(
205+
({"role": "user", "content": text}, {"role": "assistant", "content": reply})
206+
)
207+
return reply
208+
209+
messages.append(assistant)
210+
for tool_call in tool_calls:
211+
function = tool_call.get("function") or {}
212+
try:
213+
arguments = json.loads(function.get("arguments") or "{}")
214+
except (TypeError, json.JSONDecodeError):
215+
arguments = {}
216+
if not isinstance(arguments, dict):
217+
result = json.dumps({"error": "Tool arguments must be a JSON object."})
218+
elif function.get("name") in MUTATING_TOOLS:
219+
action = PendingAction(function["name"], arguments)
220+
with _state_lock:
221+
_pending_actions[key] = action
222+
# Do not rely on the model to reproduce a safety prompt. Return
223+
# the exact confirmation instructions immediately and avoid an
224+
# unnecessary second model round.
225+
return _confirmation_text(action)
226+
else:
227+
result = _call_mcp(function.get("name", ""), arguments, is_admin=is_admin)
228+
messages.append(
229+
{"role": "tool", "tool_call_id": tool_call.get("id", ""), "content": result}
230+
)
231+
232+
raise RuntimeError("The assistant exceeded the MCP tool-call limit.")
233+
234+
235+
def reset(chat_id: int, telegram_user_id: int | None = None) -> None:
236+
key = _conversation_key(chat_id, telegram_user_id)
237+
with _state_lock:
238+
_history.pop(key, None)
239+
_pending_actions.pop(key, None)

app/integrations/telegram_client.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44

55
class TelegramClient:
6+
MESSAGE_CHUNK_SIZE = 3900
7+
68
def __init__(self, bot_token: str, chat_id: str, thread_id: str = ""):
79
self.bot_token = bot_token
810
self.chat_id = chat_id
@@ -29,6 +31,66 @@ def send_message(self, message: str) -> bool:
2931
except RequestException:
3032
return False
3133

34+
def send_message_with_id(self, message: str) -> int | None:
35+
"""Send a message and return its Telegram message ID when available."""
36+
if not self.bot_token or not self.chat_id:
37+
return None
38+
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
39+
payload = {"chat_id": self.chat_id, "text": message}
40+
thread_id = self._thread_id()
41+
if thread_id is not None:
42+
payload["message_thread_id"] = thread_id
43+
try:
44+
response = requests.post(url, json=payload, timeout=10)
45+
if response.status_code != 200:
46+
return None
47+
body = response.json()
48+
if not body.get("ok"):
49+
return None
50+
message_id = (body.get("result") or {}).get("message_id")
51+
return int(message_id) if message_id is not None else None
52+
except (RequestException, TypeError, ValueError):
53+
return None
54+
55+
def delete_message(self, message_id: int | None) -> bool:
56+
"""Delete a previously sent message, if Telegram returned an ID for it."""
57+
if not self.bot_token or not self.chat_id or message_id is None:
58+
return False
59+
url = f"https://api.telegram.org/bot{self.bot_token}/deleteMessage"
60+
try:
61+
return self._telegram_ok(
62+
url,
63+
{"chat_id": self.chat_id, "message_id": int(message_id)},
64+
)
65+
except (RequestException, TypeError, ValueError):
66+
return False
67+
68+
@classmethod
69+
def _message_chunks(cls, message: str) -> list[str]:
70+
"""Split text below Telegram's limit, preferring readable boundaries."""
71+
remaining = str(message or "")
72+
if not remaining:
73+
return []
74+
chunks = []
75+
while len(remaining) > cls.MESSAGE_CHUNK_SIZE:
76+
boundary = remaining.rfind("\n", 0, cls.MESSAGE_CHUNK_SIZE + 1)
77+
if boundary < cls.MESSAGE_CHUNK_SIZE // 2:
78+
boundary = remaining.rfind(" ", 0, cls.MESSAGE_CHUNK_SIZE + 1)
79+
if boundary < cls.MESSAGE_CHUNK_SIZE // 2:
80+
boundary = cls.MESSAGE_CHUNK_SIZE
81+
chunks.append(remaining[:boundary].rstrip())
82+
remaining = remaining[boundary:].lstrip()
83+
if remaining:
84+
chunks.append(remaining)
85+
return chunks
86+
87+
def send_long_message(self, message: str) -> bool:
88+
"""Send every chunk of a potentially long assistant response."""
89+
chunks = self._message_chunks(message)
90+
if not chunks:
91+
return False
92+
return all(self.send_message(chunk) for chunk in chunks)
93+
3294
def send_poll_message(self, message: str, poll_id: int, options: list[str]) -> bool:
3395
if not self.bot_token or not self.chat_id:
3496
return False

0 commit comments

Comments
 (0)