teli-lib 0.1.0__py3-none-any.whl

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.
teli/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """teli — Telegram bot toolkit."""
2
+
3
+ from teli.bot import Bot
4
+
5
+ __all__ = ["Bot"]
6
+ __version__ = "0.1.0"
teli/bot.py ADDED
@@ -0,0 +1,219 @@
1
+ """Async Telegram bot — long-polling, send, handlers."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ import re
7
+ from typing import Awaitable, Callable
8
+
9
+ import httpx
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _TG = "https://api.telegram.org/bot{token}/{method}"
14
+
15
+ # msg dict → None (async)
16
+ Handler = Callable[[dict], Awaitable[None]]
17
+
18
+
19
+ class Bot:
20
+ """
21
+ Telegram bot with long-polling and handler registration.
22
+
23
+ Two usage modes:
24
+
25
+ 1. One-shot (CLI / scripts) — async context manager:
26
+ async with Bot.from_connection("name") as bot:
27
+ await bot.send_message(chat_id, "hola")
28
+
29
+ 2. Background task (FastAPI lifespan):
30
+ bot = Bot.from_connection("name")
31
+ bot.on_command("enchufe")(my_handler)
32
+
33
+ # in lifespan:
34
+ await bot.start()
35
+ yield
36
+ await bot.stop()
37
+ """
38
+
39
+ def __init__(self, token: str, allowed_chats: list[str] | None = None):
40
+ self._token = token
41
+ self._allowed: set[str] = {str(c) for c in (allowed_chats or [])}
42
+ self._handlers: list[Handler] = []
43
+ self._client: httpx.AsyncClient | None = None
44
+ self._task: asyncio.Task | None = None
45
+
46
+ # ── HTTP helpers ──────────────────────────────────────────────────────────
47
+
48
+ def _url(self, method: str) -> str:
49
+ return _TG.format(token=self._token, method=method)
50
+
51
+ async def _call(self, method: str, **kwargs) -> dict:
52
+ assert self._client, "Bot not started — use 'async with bot' or 'await bot.start()'"
53
+ r = await self._client.post(self._url(method), json={k: v for k, v in kwargs.items() if v is not None})
54
+ r.raise_for_status()
55
+ data = r.json()
56
+ if not data.get("ok"):
57
+ raise RuntimeError(f"Telegram API error: {data.get('description', data)}")
58
+ return data.get("result")
59
+
60
+ # ── Public API ────────────────────────────────────────────────────────────
61
+
62
+ async def get_me(self) -> dict:
63
+ """Return bot identity (username, id, etc.)."""
64
+ return await self._call("getMe")
65
+
66
+ async def send_message(self, chat_id: str | int, text: str, parse_mode: str = "HTML", **kwargs) -> dict:
67
+ return await self._call("sendMessage", chat_id=chat_id, text=text, parse_mode=parse_mode, **kwargs)
68
+
69
+ async def send_photo(self, chat_id: str | int, photo: bytes, caption: str | None = None) -> dict:
70
+ assert self._client
71
+ data: dict = {"chat_id": str(chat_id)}
72
+ if caption:
73
+ data["caption"] = caption
74
+ r = await self._client.post(
75
+ self._url("sendPhoto"),
76
+ data=data,
77
+ files={"photo": ("photo.jpg", photo, "image/jpeg")},
78
+ timeout=30,
79
+ )
80
+ r.raise_for_status()
81
+ return r.json().get("result", {})
82
+
83
+ async def send_document(self, chat_id: str | int, content: bytes, filename: str, caption: str | None = None) -> dict:
84
+ assert self._client
85
+ data: dict = {"chat_id": str(chat_id)}
86
+ if caption:
87
+ data["caption"] = caption
88
+ r = await self._client.post(
89
+ self._url("sendDocument"),
90
+ data=data,
91
+ files={"document": (filename, content, "application/octet-stream")},
92
+ timeout=30,
93
+ )
94
+ r.raise_for_status()
95
+ return r.json().get("result", {})
96
+
97
+ # ── Handler registration ──────────────────────────────────────────────────
98
+
99
+ def on_message(self, handler: Handler) -> Handler:
100
+ """Register a catch-all handler called for every allowed message."""
101
+ self._handlers.append(handler)
102
+ return handler
103
+
104
+ def on_command(self, command: str):
105
+ """Decorator: handle messages starting with /<command>."""
106
+ cmd_lower = command.lower().lstrip("/")
107
+
108
+ def decorator(fn: Handler) -> Handler:
109
+ async def wrapper(msg: dict) -> None:
110
+ text = msg.get("text", "").strip()
111
+ if not text.startswith("/"):
112
+ return
113
+ first = text.split()[0].lstrip("/").split("@")[0].lower()
114
+ if first == cmd_lower:
115
+ await fn(msg)
116
+
117
+ self._handlers.append(wrapper)
118
+ return fn
119
+
120
+ return decorator
121
+
122
+ def on_text(self, pattern: str | None = None, flags: int = re.IGNORECASE):
123
+ """Decorator: handle text messages matching an optional regex pattern."""
124
+ rx = re.compile(pattern, flags) if pattern else None
125
+
126
+ def decorator(fn: Handler) -> Handler:
127
+ async def wrapper(msg: dict) -> None:
128
+ text = msg.get("text", "")
129
+ if rx is None or rx.search(text):
130
+ await fn(msg)
131
+
132
+ self._handlers.append(wrapper)
133
+ return fn
134
+
135
+ return decorator
136
+
137
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
138
+
139
+ async def start(self) -> None:
140
+ """Open HTTP client and start background polling task."""
141
+ self._client = httpx.AsyncClient(timeout=35)
142
+ self._task = asyncio.create_task(self._poll_loop(), name=f"teli-poll")
143
+ logger.info("teli: polling started")
144
+
145
+ async def stop(self) -> None:
146
+ """Cancel polling task and close HTTP client."""
147
+ if self._task:
148
+ self._task.cancel()
149
+ try:
150
+ await self._task
151
+ except asyncio.CancelledError:
152
+ pass
153
+ self._task = None
154
+ if self._client:
155
+ await self._client.aclose()
156
+ self._client = None
157
+ logger.info("teli: polling stopped")
158
+
159
+ # Async context manager (one-shot use)
160
+ async def __aenter__(self) -> "Bot":
161
+ self._client = httpx.AsyncClient(timeout=35)
162
+ return self
163
+
164
+ async def __aexit__(self, *_) -> None:
165
+ if self._client:
166
+ await self._client.aclose()
167
+ self._client = None
168
+
169
+ # ── Internal ──────────────────────────────────────────────────────────────
170
+
171
+ async def _poll_loop(self) -> None:
172
+ offset = 0
173
+ while True:
174
+ try:
175
+ updates = await self._call("getUpdates", offset=offset, timeout=30, limit=10)
176
+ for update in updates or []:
177
+ offset = update["update_id"] + 1
178
+ msg = update.get("message") or update.get("edited_message")
179
+ if msg:
180
+ await self._dispatch(msg)
181
+ except asyncio.CancelledError:
182
+ break
183
+ except Exception as e:
184
+ logger.warning("teli poll error: %s", e)
185
+ await asyncio.sleep(5)
186
+
187
+ async def _dispatch(self, msg: dict) -> None:
188
+ # Telegram Mini Apps send commands via sendData(); normalise to text so handlers work unchanged.
189
+ if "web_app_data" in msg and "text" not in msg:
190
+ msg["text"] = msg["web_app_data"].get("data", "")
191
+ chat_id = str(msg.get("chat", {}).get("id", ""))
192
+ if self._allowed and chat_id not in self._allowed:
193
+ logger.debug("teli: ignored message from chat %s (not in allowed list)", chat_id)
194
+ return
195
+ for handler in self._handlers:
196
+ try:
197
+ await handler(msg)
198
+ except Exception as e:
199
+ logger.error("teli handler error: %s", e)
200
+
201
+ # ── Constructors ──────────────────────────────────────────────────────────
202
+
203
+ @classmethod
204
+ def from_connection(cls, name: str, data_dir=None) -> "Bot":
205
+ """Load a named connection from data/connections.json."""
206
+ from teli.connection import load_connection
207
+
208
+ conn = load_connection(name, data_dir)
209
+ return cls(token=conn["token"], allowed_chats=conn.get("allowed_chats", []))
210
+
211
+ @classmethod
212
+ def from_env(cls) -> "Bot":
213
+ """Load from TELEGRAM_BOT_TOKEN + TELEGRAM_ALLOWED_CHATS env vars."""
214
+ import os
215
+
216
+ token = os.environ["TELEGRAM_BOT_TOKEN"]
217
+ raw = os.getenv("TELEGRAM_ALLOWED_CHATS", "")
218
+ chats = [c.strip() for c in raw.split(",") if c.strip()]
219
+ return cls(token=token, allowed_chats=chats)
teli/cli.py ADDED
@@ -0,0 +1,350 @@
1
+ """teli — Telegram bot CLI.
2
+
3
+ Commands:
4
+ teli add <name> --token TOKEN [--allow CHAT_ID...] Register a named connection
5
+ teli update <name> [--allow CHAT_ID...] Update allowed chat IDs
6
+ teli list List all connections
7
+ teli remove <name> Remove a connection
8
+ teli whoami <name> Get bot identity (getMe)
9
+ teli send <name> <chat_id> <message> Send a text message
10
+ teli listen <name> [--webhook URL] Run long-polling daemon
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import importlib
16
+ import json
17
+ import logging
18
+ import sys
19
+
20
+ import click
21
+ from dotenv import load_dotenv
22
+
23
+ load_dotenv()
24
+
25
+
26
+ @click.group()
27
+ def main():
28
+ """teli — Telegram bot management."""
29
+
30
+
31
+ # ── add ───────────────────────────────────────────────────────────────────────
32
+
33
+ @main.command()
34
+ @click.argument("name")
35
+ @click.option("--token", required=True, help="Bot token from @BotFather (123456:ABC...)")
36
+ @click.option("--allow", multiple=True, metavar="CHAT_ID", help="Allowed chat IDs (repeat for multiple). Leave empty to allow all.")
37
+ def add(name: str, token: str, allow: tuple[str, ...]) -> None:
38
+ """Register a named bot connection.
39
+
40
+ Run 'teli listen <name>' then send /start to the bot to discover
41
+ your chat_id, then run 'teli update <name> --allow <chat_id>'.
42
+ """
43
+ from teli.connection import save_connection
44
+
45
+ conn = {"name": name, "token": token, "allowed_chats": list(allow)}
46
+ save_connection(conn)
47
+ chats_str = ", ".join(allow) if allow else "all (unrestricted — add --allow to lock it down)"
48
+ click.echo(f"Connection '{name}' saved.")
49
+ click.echo(f" allowed_chats: {chats_str}")
50
+ if not allow:
51
+ click.echo("\nTip: run 'teli listen <name>', send /start to the bot,")
52
+ click.echo(" then 'teli update <name> --allow <your_chat_id>'.")
53
+
54
+
55
+ # ── update ────────────────────────────────────────────────────────────────────
56
+
57
+ @main.command()
58
+ @click.argument("name")
59
+ @click.option("--allow", multiple=True, metavar="CHAT_ID", help="Replace allowed chat IDs (empty = allow all).")
60
+ @click.option("--token", default=None, help="Update bot token.")
61
+ def update(name: str, allow: tuple[str, ...], token: str | None) -> None:
62
+ """Update a connection's token or allowed chat IDs."""
63
+ from teli.connection import load_connection, save_connection
64
+
65
+ conn = load_connection(name)
66
+ if token:
67
+ conn["token"] = token
68
+ if allow or token:
69
+ if allow:
70
+ conn["allowed_chats"] = list(allow)
71
+ save_connection(conn)
72
+ chats_str = ", ".join(conn["allowed_chats"]) or "all (unrestricted)"
73
+ click.echo(f"Connection '{name}' updated.")
74
+ click.echo(f" allowed_chats: {chats_str}")
75
+ else:
76
+ click.echo(f"Nothing to update. Pass --allow or --token.")
77
+
78
+
79
+ # ── list ──────────────────────────────────────────────────────────────────────
80
+
81
+ @main.command("list")
82
+ def list_connections() -> None:
83
+ """List all registered connections."""
84
+ from teli.connection import load_connections
85
+
86
+ conns = load_connections()
87
+ if not conns:
88
+ click.echo("No connections. Use 'teli add <name> --token <TOKEN>'.")
89
+ return
90
+ for c in conns:
91
+ chats = ", ".join(c.get("allowed_chats", [])) or "all (unrestricted)"
92
+ token_hint = c["token"][:12] + "..."
93
+ click.echo(f" {c['name']:<20s} token={token_hint} chats={chats}")
94
+
95
+
96
+ # ── remove ────────────────────────────────────────────────────────────────────
97
+
98
+ @main.command()
99
+ @click.argument("name")
100
+ def remove(name: str) -> None:
101
+ """Remove a named connection."""
102
+ from teli.connection import delete_connection
103
+
104
+ try:
105
+ delete_connection(name)
106
+ click.echo(f"Connection '{name}' removed.")
107
+ except KeyError as e:
108
+ click.echo(str(e), err=True)
109
+ sys.exit(1)
110
+
111
+
112
+ # ── whoami ────────────────────────────────────────────────────────────────────
113
+
114
+ @main.command()
115
+ @click.argument("name")
116
+ def whoami(name: str) -> None:
117
+ """Get bot identity (getMe) for a named connection."""
118
+ from teli.bot import Bot
119
+
120
+ async def _go() -> None:
121
+ async with Bot.from_connection(name) as bot:
122
+ info = await bot.get_me()
123
+ click.echo(json.dumps(info, indent=2, ensure_ascii=False))
124
+
125
+ asyncio.run(_go())
126
+
127
+
128
+ # ── send ──────────────────────────────────────────────────────────────────────
129
+
130
+ @main.command()
131
+ @click.argument("name")
132
+ @click.argument("chat_id")
133
+ @click.argument("message")
134
+ def send(name: str, chat_id: str, message: str) -> None:
135
+ """Send a text message via a named connection.
136
+
137
+ \b
138
+ Example:
139
+ teli send smarthome 123456789 "Hola desde la Mac"
140
+ """
141
+ from teli.bot import Bot
142
+
143
+ async def _go() -> None:
144
+ async with Bot.from_connection(name) as bot:
145
+ result = await bot.send_message(chat_id, message)
146
+ click.echo(f"Sent. message_id={result.get('message_id')} chat_id={chat_id}")
147
+
148
+ asyncio.run(_go())
149
+
150
+
151
+ # ── listen ────────────────────────────────────────────────────────────────────
152
+
153
+ @main.command()
154
+ @click.argument("name")
155
+ @click.option("--webhook", default=None, metavar="URL",
156
+ help="Forward incoming messages as JSON POST to this URL.")
157
+ @click.option("--verbose", "-v", is_flag=True, help="Show raw update JSON.")
158
+ def listen(name: str, webhook: str | None, verbose: bool) -> None:
159
+ """Run a long-polling daemon for a named connection (Ctrl+C to stop).
160
+
161
+ Without --webhook, prints incoming messages to stdout.
162
+ With --webhook, POSTs each message dict to the given URL.
163
+
164
+ \b
165
+ Example:
166
+ teli listen smarthome
167
+ teli listen smarthome --webhook http://localhost:9000/api/telegram
168
+ """
169
+ import urllib.error as _urlerr
170
+ import urllib.request as _urlreq
171
+
172
+ from teli.bot import Bot
173
+
174
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s %(message)s")
175
+
176
+ bot = Bot.from_connection(name)
177
+
178
+ if webhook:
179
+ @bot.on_message
180
+ async def _forward(msg: dict) -> None:
181
+ payload = json.dumps({"message": msg}, ensure_ascii=False).encode()
182
+ try:
183
+ _urlreq.urlopen(
184
+ _urlreq.Request(
185
+ webhook,
186
+ data=payload,
187
+ headers={"Content-Type": "application/json"},
188
+ ),
189
+ timeout=5,
190
+ )
191
+ except _urlerr.URLError as e:
192
+ click.echo(f"[webhook error] {e}", err=True)
193
+
194
+ click.echo(f"Listening on '{name}' → forwarding to {webhook}")
195
+ else:
196
+ @bot.on_message
197
+ async def _print(msg: dict) -> None:
198
+ chat = msg.get("chat", {})
199
+ sender = msg.get("from", {})
200
+ chat_id = chat.get("id", "?")
201
+ chat_type = chat.get("type", "?")
202
+ name_parts = [sender.get("first_name", ""), sender.get("last_name", "")]
203
+ sender_name = " ".join(p for p in name_parts if p) or sender.get("username", "?")
204
+ media = next((k for k in ("photo", "document", "voice", "sticker") if k in msg), "non-text")
205
+ text = msg.get("text") or f"[{media}]"
206
+ click.echo(f"[{chat_id}/{chat_type}] {sender_name}: {text}")
207
+ if verbose:
208
+ click.echo(json.dumps(msg, indent=2, ensure_ascii=False))
209
+
210
+ click.echo(f"Listening on '{name}'... (Ctrl+C to stop)")
211
+ click.echo("Tip: send /start to discover your chat_id.")
212
+
213
+ async def _run() -> None:
214
+ await bot.start()
215
+ try:
216
+ while True:
217
+ await asyncio.sleep(1)
218
+ except asyncio.CancelledError:
219
+ pass
220
+ finally:
221
+ await bot.stop()
222
+
223
+ try:
224
+ asyncio.run(_run())
225
+ except KeyboardInterrupt:
226
+ pass
227
+ click.echo("\nStopped.")
228
+
229
+
230
+ def _require_user_listener():
231
+ try:
232
+ from teli.user import UserListener
233
+ return UserListener
234
+ except ImportError:
235
+ click.echo("Falta telethon. Instalá con: pip install 'teli[user]'", err=True)
236
+ raise SystemExit(1)
237
+
238
+
239
+ # ── user group ────────────────────────────────────────────────────────────────
240
+
241
+ @main.group("user")
242
+ def user_group():
243
+ """Comandos de cliente de usuario Telegram (Telethon)."""
244
+
245
+
246
+ @user_group.command("setup")
247
+ @click.argument("name")
248
+ @click.option("--api-id", required=True, help="API ID de my.telegram.org")
249
+ @click.option("--api-hash", required=True, help="API hash de my.telegram.org")
250
+ @click.option("--bot-username", required=True, help="Username del bot (sin @)")
251
+ def user_setup(name: str, api_id: str, api_hash: str, bot_username: str) -> None:
252
+ """Agrega api_id / api_hash a una conexión existente para el cliente de usuario.
253
+
254
+ \b
255
+ Pasos previos:
256
+ 1. Ir a https://my.telegram.org → 'API development tools'
257
+ 2. Crear una app (nombre cualquiera) → copiar App api_id y api_hash
258
+ 3. Ejecutar este comando
259
+
260
+ \b
261
+ Ejemplo:
262
+ teli user setup punto_a_punto_bot --api-id 12345678 --api-hash abc123... --bot-username punto_a_punto_bot
263
+ """
264
+ from teli.connection import load_connection, save_connection
265
+
266
+ try:
267
+ conn = load_connection(name)
268
+ except KeyError:
269
+ click.echo(f"Conexión '{name}' no encontrada. Creala primero con 'teli add'.", err=True)
270
+ raise SystemExit(1)
271
+
272
+ conn["api_id"] = int(api_id)
273
+ conn["api_hash"] = api_hash
274
+ conn["bot_username"] = bot_username.lstrip("@")
275
+ save_connection(conn)
276
+ click.echo(f"Conexión '{name}' actualizada con credenciales de usuario.")
277
+ click.echo(f"Ahora ejecutá: teli user connect {name}")
278
+
279
+
280
+ @user_group.command("connect")
281
+ @click.argument("name")
282
+ def user_connect(name: str) -> None:
283
+ """Autentica tu cuenta de Telegram (una vez). Guarda la sesión localmente.
284
+
285
+ Telegram va a pedir tu número de teléfono y el código que te manda.
286
+ La sesión queda guardada en data/sessions/ — no necesitás repetir esto.
287
+ """
288
+ UserListener = _require_user_listener()
289
+ ul = UserListener.from_connection(name)
290
+ display = asyncio.run(ul.authenticate())
291
+ click.echo(f"Autenticado como: {display}")
292
+
293
+
294
+ @user_group.command("send")
295
+ @click.argument("name")
296
+ @click.argument("recipient")
297
+ @click.argument("message")
298
+ def user_send(name: str, recipient: str, message: str) -> None:
299
+ """Manda un mensaje desde tu cuenta a cualquier contacto o username.
300
+
301
+ \b
302
+ Ejemplo:
303
+ teli user send punto_a_punto_bot Luganense "Hola, ¿hay algún monumento?"
304
+ teli user send punto_a_punto_bot @username "Hola!"
305
+ """
306
+ UserListener = _require_user_listener()
307
+ ul = UserListener.from_connection(name)
308
+ asyncio.run(ul.send(recipient, message))
309
+ click.echo(f"Mensaje enviado a '{recipient}'.")
310
+
311
+
312
+ @user_group.command("listen")
313
+ @click.argument("name")
314
+ @click.option("--handler", default=None, metavar="MODULE:FUNC",
315
+ help="Handler externo a cargar (ej: smart_home.user_handler:handle).")
316
+ def user_listen(name: str, handler: str | None) -> None:
317
+ """Corre el daemon de usuario: escucha mensajes del bot y ejecuta comandos.
318
+
319
+ \b
320
+ Ejemplo sin handler (solo imprime mensajes recibidos):
321
+ teli user listen punto_a_punto_bot
322
+
323
+ \b
324
+ Ejemplo con handler del Smart Home:
325
+ teli user listen punto_a_punto_bot --handler smart_home.user_handler:handle
326
+ """
327
+ UserListener = _require_user_listener()
328
+ ul = UserListener.from_connection(name)
329
+
330
+ if handler:
331
+ module_path, func_name = handler.rsplit(":", 1)
332
+ if "" not in sys.path:
333
+ sys.path.insert(0, "")
334
+ mod = importlib.import_module(module_path)
335
+ fn = getattr(mod, func_name)
336
+ ul.on_bot_message(fn)
337
+ click.echo(f"Handler cargado: {handler}")
338
+ else:
339
+ @ul.on_bot_message
340
+ async def _print(event) -> None:
341
+ click.echo(f"[bot→vos] {event.text!r}")
342
+
343
+ click.echo(f"Escuchando mensajes del bot '{name}'... (Ctrl+C para detener)")
344
+ logging.basicConfig(level=logging.WARNING)
345
+
346
+ try:
347
+ asyncio.run(ul.run())
348
+ except KeyboardInterrupt:
349
+ pass
350
+ click.echo("\nStopped.")
teli/connection.py ADDED
@@ -0,0 +1,59 @@
1
+ """Named connection management — reads/writes data/connections.json."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ _DEFAULT_DATA_DIR = Path(__file__).parent.parent / "data"
9
+
10
+
11
+ def _data_dir(override: str | Path | None = None) -> Path:
12
+ if override:
13
+ return Path(override)
14
+ env = os.getenv("TELI_DATA_DIR")
15
+ if env:
16
+ return Path(env)
17
+ return _DEFAULT_DATA_DIR
18
+
19
+
20
+ def _file(data_dir=None) -> Path:
21
+ return _data_dir(data_dir) / "connections.json"
22
+
23
+
24
+ def load_connections(data_dir=None) -> list[dict]:
25
+ f = _file(data_dir)
26
+ if not f.exists():
27
+ return []
28
+ return json.loads(f.read_text(encoding="utf-8")).get("connections", [])
29
+
30
+
31
+ def load_connection(name: str, data_dir=None) -> dict:
32
+ for conn in load_connections(data_dir):
33
+ if conn["name"] == name:
34
+ return conn
35
+ raise KeyError(f"Connection '{name}' not found in {_file(data_dir)}")
36
+
37
+
38
+ def save_connection(conn: dict, data_dir=None) -> None:
39
+ f = _file(data_dir)
40
+ f.parent.mkdir(parents=True, exist_ok=True)
41
+ try:
42
+ data = json.loads(f.read_text(encoding="utf-8")) if f.exists() else {"connections": []}
43
+ except Exception:
44
+ data = {"connections": []}
45
+ data["connections"] = [c for c in data["connections"] if c["name"] != conn["name"]]
46
+ data["connections"].append(conn)
47
+ f.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
48
+
49
+
50
+ def delete_connection(name: str, data_dir=None) -> None:
51
+ f = _file(data_dir)
52
+ if not f.exists():
53
+ raise KeyError(f"No connections file at {f}")
54
+ data = json.loads(f.read_text(encoding="utf-8"))
55
+ before = len(data["connections"])
56
+ data["connections"] = [c for c in data["connections"] if c["name"] != name]
57
+ if len(data["connections"]) == before:
58
+ raise KeyError(f"Connection '{name}' not found")
59
+ f.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
teli/driver.py ADDED
@@ -0,0 +1,200 @@
1
+ """
2
+ Wavi-compatible async driver interface for teli (Telegram bots).
3
+
4
+ Exact same contract as wavi_driver.py — Pulpo can swap transports with no
5
+ orchestration changes:
6
+
7
+ import teli.driver as td # PyPI install
8
+ # or
9
+ import teli_driver as td # local shim (re-exports this module)
10
+
11
+ await td.connect("my_bot")
12
+ await td.send("my_bot", "123456789", "Hola!")
13
+ print(await td.status("my_bot")) # {"session": ..., "daemon_running": True, ...}
14
+ await td.stop("my_bot")
15
+
16
+ Mapping to wavi concepts:
17
+ session → teli connection name (data/connections.json)
18
+ contact → Telegram chat_id
19
+ new (connect) → ignored (bots are stateless, no QR flow)
20
+ qr_page (connect) → always None (Telegram needs no QR scan)
21
+ check_updates → no-op / returns empty list (long-polling is push)
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import logging
27
+ import threading
28
+ from dataclasses import dataclass, field
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass
34
+ class _Session:
35
+ bot: object
36
+ loop: asyncio.AbstractEventLoop
37
+ thread: threading.Thread
38
+ _stop: threading.Event = field(default_factory=threading.Event)
39
+ ready: bool = False
40
+
41
+
42
+ _sessions: dict[str, _Session] = {}
43
+ _lock = threading.Lock()
44
+
45
+
46
+ # ── Public interface ──────────────────────────────────────────────────────────
47
+
48
+
49
+ async def connect(session: str, new: bool = False) -> dict:
50
+ """Start a bot for the named teli connection.
51
+
52
+ `new` is a no-op — Telegram bots have no QR flow.
53
+ Idempotent: calling on an already-running session returns ok immediately.
54
+
55
+ Returns: {"ok": bool, "stdout": str, "stderr": str, "qr_page": None}
56
+ """
57
+ from teli.bot import Bot
58
+
59
+ with _lock:
60
+ existing = _sessions.get(session)
61
+ if existing and existing.ready:
62
+ return {"ok": True, "stdout": "already running", "stderr": "", "qr_page": None}
63
+
64
+ try:
65
+ bot = Bot.from_connection(session)
66
+ except KeyError as e:
67
+ return {"ok": False, "stdout": "", "stderr": str(e), "qr_page": None}
68
+
69
+ loop = asyncio.new_event_loop()
70
+ entry = _Session(bot=bot, loop=loop, thread=None) # type: ignore[arg-type]
71
+
72
+ def _run() -> None:
73
+ asyncio.set_event_loop(loop)
74
+ try:
75
+ loop.run_until_complete(_lifecycle(entry))
76
+ except Exception:
77
+ logger.exception("teli driver: session '%s' crashed", session)
78
+ finally:
79
+ entry.ready = False
80
+ if not loop.is_closed():
81
+ loop.close()
82
+
83
+ t = threading.Thread(target=_run, daemon=True, name=f"teli-{session}")
84
+ entry.thread = t
85
+
86
+ with _lock:
87
+ _sessions[session] = entry
88
+
89
+ t.start()
90
+
91
+ # Wait up to 10 s for the bot to become ready.
92
+ for _ in range(100):
93
+ if entry.ready:
94
+ return {"ok": True, "stdout": f"Bot '{session}' started", "stderr": "", "qr_page": None}
95
+ await asyncio.sleep(0.1)
96
+
97
+ return {"ok": False, "stdout": "", "stderr": f"Bot '{session}' did not become ready in time", "qr_page": None}
98
+
99
+
100
+ async def check_updates(session: str, reset: bool = False) -> dict:
101
+ """No-op for Telegram (long-polling push delivers messages automatically).
102
+
103
+ Returns the wavi-compatible shape so Pulpo's poller can call this
104
+ without transport-specific branches.
105
+
106
+ Returns: {"status": "ok", "new_inbound": []}
107
+ """
108
+ return {"status": "ok", "new_inbound": []}
109
+
110
+
111
+ async def send(session: str, contact: str, text: str) -> dict:
112
+ """Send `text` to `contact` (Telegram chat_id) via the named session.
113
+
114
+ Returns: {"ok": bool, "stdout": str, "stderr": str}
115
+ """
116
+ with _lock:
117
+ entry = _sessions.get(session)
118
+ if not entry or not entry.ready:
119
+ return {"ok": False, "stdout": "", "stderr": f"Session '{session}' not running — call connect() first."}
120
+ fut = asyncio.run_coroutine_threadsafe(
121
+ entry.bot.send_message(contact, text),
122
+ entry.loop,
123
+ )
124
+ try:
125
+ await asyncio.wrap_future(fut)
126
+ return {"ok": True, "stdout": "", "stderr": ""}
127
+ except Exception as e:
128
+ logger.warning("teli driver: send %s/%s error: %s", session, contact, e)
129
+ return {"ok": False, "stdout": "", "stderr": str(e)}
130
+
131
+
132
+ async def status(session: str) -> dict:
133
+ """Return daemon and authentication status for the named session.
134
+
135
+ Returns: {"session": str, "daemon_running": bool, "authenticated": bool, "raw": str}
136
+ """
137
+ with _lock:
138
+ entry = _sessions.get(session)
139
+ running = entry is not None and entry.ready
140
+ return {
141
+ "session": session,
142
+ "daemon_running": running,
143
+ "authenticated": running,
144
+ "raw": "ready" if running else "stopped",
145
+ }
146
+
147
+
148
+ def list_session_names() -> list[str]:
149
+ """Return connection names of all currently active bot sessions."""
150
+ with _lock:
151
+ return [name for name, e in _sessions.items() if e.ready]
152
+
153
+
154
+ async def stop(session: str) -> dict:
155
+ """Gracefully stop and remove the named bot session.
156
+
157
+ Returns: {"ok": bool, "stdout": str}
158
+ """
159
+ with _lock:
160
+ entry = _sessions.pop(session, None)
161
+ if not entry:
162
+ return {"ok": True, "stdout": f"Session '{session}' was not running."}
163
+ entry._stop.set()
164
+ loop = asyncio.get_running_loop()
165
+ await loop.run_in_executor(None, lambda: entry.thread.join(timeout=15))
166
+ return {"ok": True, "stdout": f"Bot '{session}' stopped."}
167
+
168
+
169
+ def daemon_running_by_pid(session: str) -> bool:
170
+ """Return True if the session's bot is active (equivalent to 'ready' state)."""
171
+ with _lock:
172
+ entry = _sessions.get(session)
173
+ return entry is not None and entry.ready
174
+
175
+
176
+ def add_handler(session: str, handler) -> None:
177
+ """Register an additional async message handler on an already-running session.
178
+
179
+ The handler signature is the same as Bot.on_message: async (msg: dict) -> None.
180
+ Thread-safe — can be called from any thread after connect() resolves ok.
181
+ """
182
+ with _lock:
183
+ entry = _sessions.get(session)
184
+ if not entry or not entry.ready:
185
+ raise RuntimeError(f"Session '{session}' not running — call connect() first.")
186
+ entry.bot._handlers.append(handler)
187
+
188
+
189
+ # ── Internal ──────────────────────────────────────────────────────────────────
190
+
191
+
192
+ async def _lifecycle(entry: _Session) -> None:
193
+ """Runs inside the session's dedicated event loop thread."""
194
+ await entry.bot.start()
195
+ entry.ready = True
196
+ try:
197
+ while not entry._stop.is_set():
198
+ await asyncio.sleep(0.5)
199
+ finally:
200
+ await entry.bot.stop()
teli/user.py ADDED
@@ -0,0 +1,151 @@
1
+ """Telethon user client — escucha mensajes del bot en tu cuenta y responde al bot.
2
+
3
+ Flujo:
4
+ HTML → bot.sendMessage(user_chat_id, cmd)
5
+ → Telegram entrega el msg a tu cuenta
6
+ → UserListener.on_bot_message handler corre
7
+ → handler ejecuta acción y llama user.reply(texto)
8
+ → user.reply manda el msg de tu cuenta al bot
9
+ → HTML.getUpdates() lo ve y actualiza la UI
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import Awaitable, Callable
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ _DEFAULT_SESSION_DIR = Path(__file__).parent.parent / "data" / "sessions"
21
+
22
+ Handler = Callable[["UserEvent"], Awaitable[None]]
23
+
24
+
25
+ class UserEvent:
26
+ """Wrapper de evento Telethon con helper reply()."""
27
+
28
+ def __init__(self, raw_event, client, bot_username: str):
29
+ self._ev = raw_event
30
+ self._client = client
31
+ self._bot = bot_username
32
+ self.text: str = raw_event.message.text or ""
33
+ self.chat_id: int = raw_event.chat_id
34
+
35
+ async def reply(self, text: str) -> None:
36
+ """Manda una respuesta de tu cuenta al bot (lo ve getUpdates de la HTML)."""
37
+ await self._client.send_message(self._bot, text)
38
+
39
+
40
+ class UserListener:
41
+ """
42
+ Cliente Telethon que escucha mensajes del bot en tu cuenta de usuario.
43
+
44
+ Uso en otro proyecto:
45
+ from teli.user import UserListener
46
+
47
+ ul = UserListener.from_connection("punto_a_punto_bot")
48
+ ul.on_bot_message(my_handler)
49
+ asyncio.run(ul.run())
50
+ """
51
+
52
+ def __init__(self, api_id: int, api_hash: str, bot_username: str, session_path: str | Path):
53
+ self._api_id = api_id
54
+ self._api_hash = api_hash
55
+ self._bot = bot_username.lstrip("@")
56
+ self._session = str(session_path)
57
+ self._handlers: list[Handler] = []
58
+ self._client = None
59
+
60
+ # ── Handlers ──────────────────────────────────────────────────────────────
61
+
62
+ def on_bot_message(self, handler: Handler) -> Handler:
63
+ """Registra un handler para mensajes recibidos del bot."""
64
+ self._handlers.append(handler)
65
+ return handler
66
+
67
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
68
+
69
+ async def authenticate(self) -> str:
70
+ """Authenticate once (prompts for phone + code if no session), return display name."""
71
+ from telethon import TelegramClient
72
+
73
+ client = TelegramClient(self._session, self._api_id, self._api_hash)
74
+ await client.start()
75
+ me = await client.get_me()
76
+ await client.disconnect()
77
+ return f"{me.first_name} (@{me.username or me.id})"
78
+
79
+ async def run(self) -> None:
80
+ """Autentica (si es necesario) y empieza a escuchar. Corre indefinidamente."""
81
+ from telethon import TelegramClient, events
82
+
83
+ self._client = TelegramClient(self._session, self._api_id, self._api_hash)
84
+
85
+ await self._client.start()
86
+ me = await self._client.get_me()
87
+ logger.info("teli user: conectado como @%s", me.username or me.id)
88
+
89
+ @self._client.on(events.NewMessage(from_users=self._bot))
90
+ async def _dispatch(ev):
91
+ event = UserEvent(ev, self._client, self._bot)
92
+ for handler in self._handlers:
93
+ try:
94
+ await handler(event)
95
+ except Exception as e:
96
+ logger.error("teli user handler error: %s", e)
97
+
98
+ logger.info("teli user: escuchando mensajes de @%s", self._bot)
99
+ await self._client.run_until_disconnected()
100
+
101
+ async def send(self, recipient: str, text: str) -> None:
102
+ """Manda un mensaje desde tu cuenta a cualquier contacto o username."""
103
+ from telethon import TelegramClient
104
+
105
+ client = TelegramClient(self._session, self._api_id, self._api_hash)
106
+ await client.start()
107
+ await client.send_message(recipient, text)
108
+ await client.disconnect()
109
+
110
+ async def send_to_bot(self, text: str) -> None:
111
+ """Manda un mensaje de tu cuenta al bot (útil para testing)."""
112
+ assert self._client, "Llamá a run() primero"
113
+ await self._client.send_message(self._bot, text)
114
+
115
+ async def stop(self) -> None:
116
+ if self._client:
117
+ await self._client.disconnect()
118
+
119
+ # ── Constructors ──────────────────────────────────────────────────────────
120
+
121
+ @classmethod
122
+ def from_connection(cls, connection_name: str, data_dir=None) -> "UserListener":
123
+ """
124
+ Carga un UserListener desde la configuración de una conexión nombrada.
125
+
126
+ La conexión necesita tener además de token: api_id, api_hash, bot_username.
127
+ Esos campos se agregan con: teli user setup <name>
128
+ """
129
+ from teli.connection import load_connection
130
+
131
+ conn = load_connection(connection_name, data_dir)
132
+ api_id = conn.get("api_id")
133
+ api_hash = conn.get("api_hash")
134
+ bot_username = conn.get("bot_username") or connection_name
135
+
136
+ if not api_id or not api_hash:
137
+ raise RuntimeError(
138
+ f"La conexión '{connection_name}' no tiene api_id/api_hash. "
139
+ "Ejecutá: teli user setup <name>"
140
+ )
141
+
142
+ session_dir = Path(data_dir) / "sessions" if data_dir else _DEFAULT_SESSION_DIR
143
+ session_dir.mkdir(parents=True, exist_ok=True)
144
+ session_path = session_dir / connection_name
145
+
146
+ return cls(
147
+ api_id=int(api_id),
148
+ api_hash=api_hash,
149
+ bot_username=bot_username,
150
+ session_path=session_path,
151
+ )
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: teli-lib
3
+ Version: 0.1.0
4
+ Summary: Telegram bot toolkit: named connections, long-polling, send/receive, and a wavi-compatible driver
5
+ Project-URL: Homepage, https://github.com/josetabuyo/teli
6
+ Project-URL: Source, https://github.com/josetabuyo/teli
7
+ Project-URL: Issues, https://github.com/josetabuyo/teli/issues
8
+ Author-email: José Tabuyo <josetabuyo@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: automation,bot,cli,driver,long-polling,telegram
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Communications :: Chat
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.11
23
+ Requires-Dist: click>=8.1
24
+ Requires-Dist: httpx>=0.27
25
+ Requires-Dist: python-dotenv>=1.2
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.8; extra == 'dev'
30
+ Provides-Extra: user
31
+ Requires-Dist: telethon>=1.36; extra == 'user'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # teli — Telegram bot toolkit
35
+
36
+ **teli** is a Python library and CLI for managing Telegram bots: named connections, async long-polling, message handlers, and a wavi-compatible driver interface for drop-in use alongside WhatsApp automation.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install teli
42
+ # with user-client support (Telethon):
43
+ pip install "teli[user]"
44
+ ```
45
+
46
+ ## Quick start — CLI
47
+
48
+ ```bash
49
+ # Register a bot
50
+ teli add mybot --token 123456:ABC...
51
+
52
+ # Discover your chat_id (send /start to the bot, look at output)
53
+ teli listen mybot
54
+
55
+ # Lock down allowed chats
56
+ teli update mybot --allow 987654321
57
+
58
+ # Send a message
59
+ teli send mybot 987654321 "Hello from teli!"
60
+ ```
61
+
62
+ ## Quick start — Python
63
+
64
+ ```python
65
+ from teli.bot import Bot
66
+
67
+ async with Bot.from_connection("mybot") as bot:
68
+ await bot.send_message(987654321, "Hello!")
69
+ ```
70
+
71
+ Long-polling daemon with handlers:
72
+
73
+ ```python
74
+ bot = Bot.from_connection("mybot")
75
+
76
+ @bot.on_command("start")
77
+ async def handle_start(msg):
78
+ await bot.send_message(msg["chat"]["id"], "Hi! I'm running on teli.")
79
+
80
+ await bot.start()
81
+ # ... keep alive ...
82
+ await bot.stop()
83
+ ```
84
+
85
+ ## Driver interface (wavi-compatible)
86
+
87
+ `teli.driver` exposes the same 7-function interface as `wavi_driver`, so Pulpo and other orchestrators can treat Telegram and WhatsApp interchangeably:
88
+
89
+ ```python
90
+ import teli.driver as td
91
+
92
+ await td.connect("mybot") # start bot
93
+ await td.send("mybot", "987654321", "Hola!") # contact = chat_id
94
+ print(await td.status("mybot")) # {"daemon_running": True, ...}
95
+ print(td.list_session_names()) # ["mybot"]
96
+ await td.stop("mybot")
97
+ ```
98
+
99
+ | Function | Returns | Notes |
100
+ |---|---|---|
101
+ | `connect(session, new)` | `{"ok": bool, "qr_page": None, ...}` | `new` is a no-op (no QR for bots) |
102
+ | `check_updates(session)` | `{"status": "ok", "new_inbound": []}` | no-op — Telegram is push |
103
+ | `send(session, contact, text)` | `{"ok": bool, ...}` | `contact` = chat_id |
104
+ | `status(session)` | `{"daemon_running": bool, "authenticated": bool, ...}` | |
105
+ | `list_session_names()` | `list[str]` | sync |
106
+ | `stop(session)` | `{"ok": bool, ...}` | |
107
+ | `daemon_running_by_pid(session)` | `bool` | sync |
108
+
109
+ ## Named connections
110
+
111
+ Connections are stored in `data/connections.json` (or `$TELI_DATA_DIR/connections.json`):
112
+
113
+ ```json
114
+ {
115
+ "connections": [
116
+ {
117
+ "name": "mybot",
118
+ "token": "123456:ABC...",
119
+ "allowed_chats": ["987654321"]
120
+ }
121
+ ]
122
+ }
123
+ ```
124
+
125
+ ## User client (Telethon)
126
+
127
+ The optional `[user]` extra enables a user-account listener (receive messages sent to your bot from your own Telegram account):
128
+
129
+ ```bash
130
+ teli user setup mybot --api-id 12345 --api-hash abc123 --bot-username mybotname
131
+ teli user connect mybot
132
+ teli user listen mybot
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ teli/__init__.py,sha256=knqHbBlL8L-7BS-trei3N0UxpNsVTi254ooCzcsBK30,104
2
+ teli/bot.py,sha256=TLKJxDqHeTN1CVtCdkYLSSygTvY1VvDW7dsi_C9pyO0,8759
3
+ teli/cli.py,sha256=0ViAeyNeDm-LcV1158E18AzoUJLiSoVjP2xNPZVZX_g,13245
4
+ teli/connection.py,sha256=b89n4oFHdlQ8fEG_e05m0mN5LeQgxY4-mukP1V-Ovrk,1955
5
+ teli/driver.py,sha256=CukEQF4WyEdwqVJ7x8kn2KZ8nHEY_Osml0FYTpvx_CM,6793
6
+ teli/user.py,sha256=cfh7G74W1KqRPjPyQbWHlAqmiQ9FKoS3tDtpjxX6IqQ,5881
7
+ teli_lib-0.1.0.dist-info/METADATA,sha256=7Qyn0uN1jqcvRNErX_L1aPW0vE9EG05A-pSupo7kOOU,4133
8
+ teli_lib-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
9
+ teli_lib-0.1.0.dist-info/entry_points.txt,sha256=qsnVyZSTlluHFPTGDZGlU_OZ_y4LQeCXxGo3H09cL9M,39
10
+ teli_lib-0.1.0.dist-info/licenses/LICENSE,sha256=A5dm6gP-Zs_ygdXYEazAJowsIsJoZOpfZ8iKyBbgwAk,1069
11
+ teli_lib-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ teli = teli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 José Tabuyo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.