minia_telegram 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: minia_telegram
3
+ Version: 0.1.0
4
+ Summary: Telegram integration for the minia agent framework
5
+ Requires-Python: <3.14,>=3.11
6
+ Requires-Dist: httpx
7
+ Requires-Dist: minia_contracts
@@ -0,0 +1,14 @@
1
+ """minia_telegram - Telegram integration for the minia agent framework.
2
+
3
+ This package provides a Telegram-specific implementation of the interaction layer,
4
+ allowing the agent to communicate with users via Telegram's Bot API.
5
+
6
+ Exports:
7
+ - TelegramClient: Low-level client for the Telegram Bot API.
8
+ - TelegramBridge: High-level bridge that maps Telegram updates to internal messages.
9
+ """
10
+
11
+ from minia_telegram.bridge import TelegramBridge
12
+ from minia_telegram.client import TelegramClient
13
+
14
+ __all__ = ["TelegramClient", "TelegramBridge"]
@@ -0,0 +1,229 @@
1
+ """Bridge between Telegram's raw data and the internal minia system.
2
+
3
+ This gets incoming Telegram updates into the internal format (text for now)
4
+ and provides a way for the agent to route responses back to the Telegram client.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import logging
12
+ from collections.abc import AsyncIterator
13
+
14
+ import httpx
15
+
16
+ from minia_contracts.config.settings import config
17
+ from minia_contracts.protocol.ask_user import AskUserTracker
18
+ from minia_contracts.protocol.commands import CommandSender
19
+ from minia_contracts.protocol.dispatch import as_event_consumer
20
+ from minia_contracts.protocol.events import run_event_socket_client
21
+ from minia_contracts.reports import ReportEvent, ReportEventPayload, cmd_answer, cmd_input
22
+ from minia_telegram.client import TelegramClient
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+
27
+ class TelegramBridge:
28
+ """Handles translation between Telegram and internal message formats.
29
+
30
+ Connects to the agent's event socket as a client (not a server), consuming
31
+ report events and forwarding them back through Telegram. Incoming user
32
+ messages from Telegram are sent to the agent via the command socket.
33
+ """
34
+
35
+ def __init__(self, client: TelegramClient) -> None:
36
+ self.client = client
37
+ # Ensure chat_id is always a string (TOML may parse it as an integer).
38
+ telegram_cfg = getattr(config, "telegram", None)
39
+ raw_chat_id = telegram_cfg.chat_id if telegram_cfg else ""
40
+ self.chat_id = str(raw_chat_id) if raw_chat_id else ""
41
+ log.info("[Telegram] Bridge initialized with chat_id=%s", self.chat_id)
42
+ self._sender: CommandSender | None = None
43
+ self._ask_users = AskUserTracker()
44
+
45
+ # ------------------------------------------------------------------
46
+ # Incoming: Telegram → agent
47
+ # ------------------------------------------------------------------
48
+
49
+ async def send_response(self, chat_id: str, text: str) -> None:
50
+ """Send a response back through the Telegram client, truncating if needed."""
51
+ if not chat_id:
52
+ log.error("[Telegram] Cannot send: chat_id is empty")
53
+ return
54
+
55
+ # Telegram's limit is 4096 characters per message
56
+ MAX_LENGTH = 4090
57
+ if len(text) > MAX_LENGTH:
58
+ original_len = len(text)
59
+ text = text[:MAX_LENGTH]
60
+ log.warning(
61
+ "[Telegram] Truncated response from %d to %d chars", original_len, MAX_LENGTH
62
+ )
63
+
64
+ try:
65
+ await self.client.send_message(chat_id, text)
66
+ log.info("[Telegram] Sent message to chat %s (%d chars)", chat_id, len(text))
67
+ except Exception as exc:
68
+ log.error("[Telegram] Failed to send message to chat %s: %s", exc)
69
+
70
+ async def get_updates(self) -> AsyncIterator[str]:
71
+ """Bridge method to fetch and map Telegram updates to internal Messages."""
72
+ async for update in self.client.poll_updates(self.chat_id):
73
+ yield update["message"]["text"]
74
+
75
+ def _update_ask_user(self, event: ReportEvent, payload: ReportEventPayload) -> None:
76
+ """Update the shared ask_user tracker from a report event.
77
+
78
+ Marks the agent when an ``ask_user`` tool call is observed; clears it
79
+ on the matching tool result, a final response, or an error.
80
+ """
81
+ tool = payload.get("tool", "")
82
+ agent = payload.get("agent") or "main"
83
+ if event == ReportEvent.TOOL_CALL and tool == "ask_user":
84
+ self._ask_users.mark(agent)
85
+ elif (event == ReportEvent.TOOL_RESULT and tool == "ask_user") or event in (
86
+ ReportEvent.FINAL_RESPONSE,
87
+ ReportEvent.ERROR,
88
+ ):
89
+ self._ask_users.clear(agent)
90
+
91
+ async def send_to_agent(self, text: str) -> None:
92
+ """Send a message to the agent via the command socket."""
93
+ if self._sender is None:
94
+ self._sender = CommandSender(
95
+ config.default.cmd_socket_path,
96
+ logger=log,
97
+ )
98
+ if self._ask_users.any_pending():
99
+ await self._sender.send(cmd_answer(text))
100
+ self._ask_users.clear_most_recent()
101
+ else:
102
+ await self._sender.send(cmd_input(text))
103
+
104
+ def _sanitize_for_telegram(self, text: str) -> str:
105
+ """Remove things that Telegram's Markdown parser can't handle."""
106
+ return text
107
+
108
+ # ------------------------------------------------------------------
109
+ # Outgoing: agent → Telegram (event socket client)
110
+ # ------------------------------------------------------------------
111
+
112
+ def handle(self, event: ReportEvent, payload: ReportEventPayload) -> None:
113
+ """Handle report events from the agent and forward to Telegram.
114
+
115
+ Implements :class:`minia_contracts.protocol.interfaces.EventConsumer`.
116
+ Only ``FINAL_RESPONSE`` events are forwarded as visible messages.
117
+ """
118
+ log.debug("[Telegram] handle() called with event=%s", event.value)
119
+ content = payload.get("content", "")
120
+
121
+ self._update_ask_user(event, payload)
122
+
123
+ match event:
124
+ case ReportEvent.FINAL_RESPONSE:
125
+ if content:
126
+ if payload.get("agent") == "main":
127
+ clean_content = self._sanitize_for_telegram(content)
128
+ log.info("[Telegram] Forwarding final response: '%s'", clean_content[:200])
129
+ asyncio.create_task(self.send_response(self.chat_id, clean_content))
130
+ else:
131
+ log.warning("[Telegram] FINAL_RESPONSE with empty content")
132
+ case ReportEvent.REASONING_CONTENT:
133
+ log.debug("[Telegram] Agent thinking: '%s'", content[:200])
134
+ case ReportEvent.ERROR:
135
+ log.warning("[Telegram] Agent error: %s", content[:200])
136
+ case ReportEvent.TOOL_CALL:
137
+ tool = payload.get("tool", "")
138
+ if tool == "ask_user":
139
+ question = payload.get("args", {}).get("question", "")
140
+ if question:
141
+ clean_question = self._sanitize_for_telegram(question)
142
+ log.info("[Telegram] ask_user question: '%s'", clean_question[:200])
143
+ asyncio.create_task(
144
+ self.send_response(self.chat_id, f"💬 {clean_question}")
145
+ )
146
+ else:
147
+ log.debug("[Telegram] Tool call: %s", content[:200])
148
+ case ReportEvent.TOOL_RESULT:
149
+ tool = payload.get("tool", "")
150
+ if tool == "ask_user":
151
+ log.info("[Telegram] ask_user answered: '%s'", content[:200])
152
+ else:
153
+ log.debug("[Telegram] Tool result: %s", content[:200])
154
+ case ReportEvent.USER_INPUT:
155
+ log.debug("[Telegram] USER_INPUT: '%s'", payload.get("content", "")[:200])
156
+ case (
157
+ ReportEvent.ITERATION_START
158
+ | ReportEvent.ITERATION_END
159
+ | ReportEvent.REFUSAL
160
+ | ReportEvent.LIVE_TOKEN
161
+ | ReportEvent.STATS
162
+ | ReportEvent.TOKEN_WARNING
163
+ | ReportEvent.RETRY
164
+ | ReportEvent.WARNING
165
+ | ReportEvent.COMMAND_RECEIVED
166
+ | ReportEvent.TTS_START
167
+ | ReportEvent.TTS_END
168
+ | ReportEvent.TTS_STOP
169
+ | ReportEvent.RUN_START
170
+ | ReportEvent.RUN_END
171
+ | ReportEvent.RUN_STOPPED
172
+ | ReportEvent.RUN_HEARTBEAT
173
+ | ReportEvent.BACKGROUND_DONE
174
+ | ReportEvent.DELEGATE_RELAY_DONE
175
+ | ReportEvent.PROGRESS_UPDATE
176
+ ):
177
+ log.debug("[Telegram] Event %s: '%s'", event.value, content[:100])
178
+
179
+ async def connect_to_event_socket(self) -> None:
180
+ """Connect to the agent's event socket and start consuming events."""
181
+ log.info("[Telegram] Connecting to event socket: %s", config.default.event_socket_path)
182
+ try:
183
+ await run_event_socket_client(
184
+ config.default.event_socket_path,
185
+ as_event_consumer(self),
186
+ auto_reconnect=True,
187
+ client_type="telegram",
188
+ )
189
+ log.info("[Telegram] Event socket connection established")
190
+ except Exception as exc:
191
+ log.error("[Telegram] Failed to connect to event socket: %s", exc)
192
+ raise
193
+
194
+ async def run(self) -> None:
195
+ """Run the bidirectional bridge.
196
+
197
+ Runs both directions concurrently:
198
+ - Incoming: polls Telegram for user messages, sends them to agent
199
+ - Outgoing: connects to event socket, forwards agent responses to Telegram
200
+ """
201
+ log.info("[Telegram] Starting bidirectional bridge")
202
+
203
+ # Start event socket consumer in background
204
+ event_task = asyncio.create_task(self.connect_to_event_socket())
205
+ event_task.add_done_callback(
206
+ lambda t: (
207
+ log.info("[Telegram] Event socket task completed successfully")
208
+ if not t.cancelled() and not t.exception()
209
+ else log.error("[Telegram] Event socket task failed: %s", t.exception())
210
+ )
211
+ )
212
+
213
+ try:
214
+ # Poll for incoming messages from Telegram and forward to agent
215
+ async for message in self.get_updates():
216
+ if message.strip():
217
+ log.info("[Telegram] Received user message: '%s'", message[:200])
218
+ await self.send_to_agent(message)
219
+ except (ConnectionError, httpx.ConnectTimeout, httpx.ConnectError) as exc:
220
+ log.error("[Telegram] Network error during polling: %s", exc)
221
+ # Don't crash - just log and let the event socket reconnect
222
+ except asyncio.CancelledError:
223
+ log.info("[Telegram] Bridge cancelled")
224
+ event_task.cancel()
225
+ raise
226
+ finally:
227
+ event_task.cancel()
228
+ with contextlib.suppress(asyncio.CancelledError):
229
+ await event_task
@@ -0,0 +1,83 @@
1
+ """Telegram client for the minia agent framework.
2
+
3
+ This module provides a Telegram-specific implementation of the interaction layer,
4
+ allowing the agent to communicate with users via Telegram's Bot API."""
5
+
6
+ import asyncio
7
+ import logging
8
+ import re
9
+ from collections.abc import AsyncIterator
10
+
11
+ import httpx
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ class TelegramClient:
17
+ """A client to interact with the Telegram Bot API."""
18
+
19
+ _TOKEN_PATTERN = re.compile(r"^\d{8,}:[A-Za-z0-9_-]{9,}$")
20
+
21
+ def __init__(self, bot_token: str) -> None:
22
+ """Initialize the Telegram client with a valid bot token.
23
+
24
+ Args:
25
+ bot_token: The Telegram Bot API token (format: '1234567890:ABCdef...').
26
+
27
+ Raises:
28
+ ValueError: If the token is invalid or doesn't match expected format.
29
+ """
30
+ if not bot_token or not self._TOKEN_PATTERN.match(bot_token):
31
+ raise ValueError(
32
+ f"Invalid Telegram bot token: {bot_token!r}. "
33
+ "Expected format '1234567890:ABCdefGhIjKlMnOpQrStUvWxYz'."
34
+ )
35
+ self.bot_token = bot_token
36
+ self.api_base = f"https://api.telegram.org/bot{self.bot_token}"
37
+
38
+ async def send_message(self, chat_id: str, text: str) -> None:
39
+ """Send a message to a specific Telegram chat."""
40
+ url = f"{self.api_base}/sendMessage"
41
+ payload = {"chat_id": chat_id, "text": text, "parse_mode": "markdown"}
42
+ async with httpx.AsyncClient() as client:
43
+ response = await client.post(url, json=payload)
44
+ if response.status_code == 200:
45
+ log.debug("Successfully sent message to Telegram chat %s", chat_id)
46
+ else:
47
+ log.error("Failed to send message to Telegram: %s", response.text)
48
+
49
+ async def poll_updates(self, chat_id: str | None = None) -> AsyncIterator[dict]:
50
+ """Continuously poll for new updates from the Telegram API.
51
+
52
+ Includes a 25-second sleep between iterations as requested.
53
+ """
54
+ offset = 0
55
+ while True:
56
+ url = f"{self.api_base}/getUpdates"
57
+ params = {"offset": offset}
58
+
59
+ async with httpx.AsyncClient() as client:
60
+ response = await client.get(url, params=params)
61
+
62
+ if response.status_code == 200:
63
+ updates = response.json().get("result", [])
64
+
65
+ for update in updates:
66
+ offset = update["update_id"] + 1
67
+
68
+ # Ensure we only process messages for the configured chat.
69
+ # Convert chat_id to str to handle TOML config where
70
+ # chat_id may be an integer (e.g. 1071941511) — always
71
+ # compare strings so the check works regardless of config type.
72
+ if chat_id and "message" in update:
73
+ msg_chat = str(update["message"]["chat"]["id"])
74
+ if msg_chat != str(chat_id):
75
+ continue
76
+
77
+ yield update
78
+
79
+ # The 25-second pause between polls
80
+ await asyncio.sleep(25)
81
+ else:
82
+ log.error("Failed to poll updates from Telegram: %s", response.text)
83
+ await asyncio.sleep(25)
@@ -0,0 +1,74 @@
1
+ """minia_telegram - Telegram client for minia.
2
+
3
+ Polls incoming messages from Telegram and sends them to the minia agent input socket.
4
+
5
+ Usage:
6
+ minia-telegram
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+
13
+ from minia_contracts.config.settings import config
14
+ from minia_contracts.utils.cli import apply_config_flag, run_cli
15
+ from minia_contracts.utils.logging import get_logger, resolve_log_level
16
+ from minia_telegram.bridge import TelegramBridge
17
+ from minia_telegram.client import TelegramClient
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ class TelegramListener:
23
+ """Poll Telegram -> bridge to internal Message format -> send to agent."""
24
+
25
+ def __init__(self) -> None:
26
+ # Ensure chat_id is always a string (TOML may parse it as an integer).
27
+ raw_chat_id = getattr(config.telegram, "chat_id", "") or ""
28
+ self.chat_id = str(raw_chat_id) if raw_chat_id else ""
29
+
30
+ async def start(self) -> None:
31
+ """Initialize the Telegram client and bridge."""
32
+ logger.info("[Telegram] Initializing...")
33
+
34
+ bot_token = getattr(config.telegram, "bot_token", "")
35
+ if not bot_token:
36
+ raise RuntimeError(
37
+ "[Telegram] Missing bot_token in config. Set it under [telegram] section."
38
+ )
39
+
40
+ self.client = TelegramClient(bot_token)
41
+ self.bridge = TelegramBridge(self.client)
42
+
43
+ logger.info("[Telegram] Ready")
44
+
45
+ async def run(self) -> None:
46
+ """Main entry point. Runs the bidirectional bridge."""
47
+ assert self.bridge is not None, "start() must be called before run()"
48
+ logger.info("[Telegram] Starting bidirectional bridge")
49
+
50
+ try:
51
+ await self.bridge.run()
52
+ except asyncio.CancelledError:
53
+ logger.info("[Telegram] Bridge cancelled")
54
+ raise
55
+ except Exception:
56
+ logger.exception("[Telegram] Error in bridge")
57
+ raise
58
+
59
+
60
+ async def _main() -> None:
61
+ listener = TelegramListener()
62
+ await listener.start()
63
+ await listener.run()
64
+
65
+
66
+ def main() -> None:
67
+ """Entry point for minia-telegram."""
68
+ apply_config_flag()
69
+ log_level = resolve_log_level(config, "telegram")
70
+ run_cli(log_level, _main, add_console=True)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: minia_telegram
3
+ Version: 0.1.0
4
+ Summary: Telegram integration for the minia agent framework
5
+ Requires-Python: <3.14,>=3.11
6
+ Requires-Dist: httpx
7
+ Requires-Dist: minia_contracts
@@ -0,0 +1,11 @@
1
+ pyproject.toml
2
+ minia_telegram/__init__.py
3
+ minia_telegram/bridge.py
4
+ minia_telegram/client.py
5
+ minia_telegram/main.py
6
+ minia_telegram.egg-info/PKG-INFO
7
+ minia_telegram.egg-info/SOURCES.txt
8
+ minia_telegram.egg-info/dependency_links.txt
9
+ minia_telegram.egg-info/entry_points.txt
10
+ minia_telegram.egg-info/requires.txt
11
+ minia_telegram.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ minia-telegram = minia_telegram.main:main
@@ -0,0 +1,2 @@
1
+ httpx
2
+ minia_contracts
@@ -0,0 +1 @@
1
+ minia_telegram
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "minia_telegram"
7
+ version = "0.1.0"
8
+ description = "Telegram integration for the minia agent framework"
9
+ requires-python = ">=3.11,<3.14"
10
+ dependencies = [
11
+ "httpx",
12
+ "minia_contracts",
13
+ ]
14
+
15
+ [project.scripts]
16
+ minia-telegram = "minia_telegram.main:main"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["."]
20
+ include = ["minia_telegram", "minia_telegram.*"]
21
+
22
+ [tool.uv.sources]
23
+ minia_contracts = { workspace = true }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+