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 +6 -0
- teli/bot.py +219 -0
- teli/cli.py +350 -0
- teli/connection.py +59 -0
- teli/driver.py +200 -0
- teli/user.py +151 -0
- teli_lib-0.1.0.dist-info/METADATA +137 -0
- teli_lib-0.1.0.dist-info/RECORD +11 -0
- teli_lib-0.1.0.dist-info/WHEEL +4 -0
- teli_lib-0.1.0.dist-info/entry_points.txt +2 -0
- teli_lib-0.1.0.dist-info/licenses/LICENSE +21 -0
teli/__init__.py
ADDED
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,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.
|