steeper 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.
- steeper/__init__.py +16 -0
- steeper/_client.py +59 -0
- steeper/_config.py +34 -0
- steeper/integrations/__init__.py +0 -0
- steeper/integrations/aiogram.py +136 -0
- steeper/integrations/ptb.py +176 -0
- steeper/integrations/telebot.py +167 -0
- steeper/py.typed +0 -0
- steeper-0.1.0.dist-info/METADATA +130 -0
- steeper-0.1.0.dist-info/RECORD +11 -0
- steeper-0.1.0.dist-info/WHEEL +4 -0
steeper/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Steeper — Telegram bot middleware for the Steeper platform."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name: str):
|
|
7
|
+
if name == "SteeperConfig":
|
|
8
|
+
from steeper._config import SteeperConfig
|
|
9
|
+
return SteeperConfig
|
|
10
|
+
if name == "SteeperClient":
|
|
11
|
+
from steeper._client import SteeperClient
|
|
12
|
+
return SteeperClient
|
|
13
|
+
raise AttributeError(f"module 'steeper' has no attribute {name!r}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = ["SteeperConfig", "SteeperClient"]
|
steeper/_client.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from steeper._config import SteeperConfig
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("steeper")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SteeperClient:
|
|
15
|
+
"""Async HTTP client that forwards data to the Steeper backend."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, config: SteeperConfig, *, timeout: float = 10.0) -> None:
|
|
18
|
+
self._config = config
|
|
19
|
+
self._http = httpx.AsyncClient(timeout=timeout)
|
|
20
|
+
|
|
21
|
+
async def forward_update(self, update: dict[str, Any]) -> None:
|
|
22
|
+
"""POST a raw Telegram Update to the Steeper webhook endpoint."""
|
|
23
|
+
try:
|
|
24
|
+
resp = await self._http.post(
|
|
25
|
+
self._config.webhook_url,
|
|
26
|
+
json=update,
|
|
27
|
+
headers={
|
|
28
|
+
"x-telegram-bot-api-secret-token": self._config.token_hash,
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
resp.raise_for_status()
|
|
32
|
+
except httpx.HTTPError as exc:
|
|
33
|
+
logger.warning("Steeper webhook failed: %s", exc)
|
|
34
|
+
|
|
35
|
+
async def log_bot_message(
|
|
36
|
+
self,
|
|
37
|
+
chat_id: int,
|
|
38
|
+
text: str,
|
|
39
|
+
message_id: int,
|
|
40
|
+
date: int | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""POST a bot-sent message to the Steeper bot-message endpoint."""
|
|
43
|
+
payload = {
|
|
44
|
+
"chat_id": chat_id,
|
|
45
|
+
"text": text,
|
|
46
|
+
"message_id": message_id,
|
|
47
|
+
"date": date or int(time.time()),
|
|
48
|
+
}
|
|
49
|
+
try:
|
|
50
|
+
resp = await self._http.post(
|
|
51
|
+
self._config.bot_message_url,
|
|
52
|
+
json=payload,
|
|
53
|
+
)
|
|
54
|
+
resp.raise_for_status()
|
|
55
|
+
except httpx.HTTPError as exc:
|
|
56
|
+
logger.warning("Steeper bot-message log failed: %s", exc)
|
|
57
|
+
|
|
58
|
+
async def close(self) -> None:
|
|
59
|
+
await self._http.aclose()
|
steeper/_config.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class SteeperConfig:
|
|
9
|
+
"""Immutable configuration for the Steeper middleware.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
base_url: Steeper backend URL (e.g. ``http://localhost:8000``).
|
|
13
|
+
bot_id: UUID of the bot registered in Steeper.
|
|
14
|
+
bot_token: Raw Telegram bot token from BotFather.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
base_url: str
|
|
18
|
+
bot_id: str
|
|
19
|
+
bot_token: str
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def token_hash(self) -> str:
|
|
23
|
+
"""SHA-256 hex digest of the bot token, used by the backend for auth."""
|
|
24
|
+
return hashlib.sha256(self.bot_token.encode()).hexdigest()
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def webhook_url(self) -> str:
|
|
28
|
+
base = self.base_url.rstrip("/")
|
|
29
|
+
return f"{base}/v1/communications/webhook/{self.bot_id}"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def bot_message_url(self) -> str:
|
|
33
|
+
base = self.base_url.rstrip("/")
|
|
34
|
+
return f"{base}/v1/communications/webhook/{self.token_hash}/bot-message"
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Steeper middleware for **aiogram v3**.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from aiogram import Bot, Dispatcher
|
|
6
|
+
from steeper.integrations.aiogram import SteeperMiddleware
|
|
7
|
+
|
|
8
|
+
bot = Bot(token=BOT_TOKEN)
|
|
9
|
+
dp = Dispatcher()
|
|
10
|
+
|
|
11
|
+
steeper = SteeperMiddleware(
|
|
12
|
+
base_url="http://localhost:8000",
|
|
13
|
+
bot_id="<uuid>",
|
|
14
|
+
bot_token=BOT_TOKEN,
|
|
15
|
+
)
|
|
16
|
+
steeper.setup(dp, bot)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from typing import Any, Callable, Awaitable
|
|
23
|
+
|
|
24
|
+
from steeper._client import SteeperClient
|
|
25
|
+
from steeper._config import SteeperConfig
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("steeper.aiogram")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from aiogram import BaseMiddleware, Bot, Dispatcher
|
|
31
|
+
from aiogram.types import Update, Message
|
|
32
|
+
except ImportError as _exc:
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"aiogram>=3.0 is required for this integration. "
|
|
35
|
+
"Install it with: pip install steeper[aiogram]"
|
|
36
|
+
) from _exc
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _IncomingMiddleware(BaseMiddleware):
|
|
40
|
+
"""Outer middleware on ``Update`` — forwards raw updates to Steeper."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, client: SteeperClient) -> None:
|
|
43
|
+
self._client = client
|
|
44
|
+
|
|
45
|
+
async def __call__(
|
|
46
|
+
self,
|
|
47
|
+
handler: Callable[[Update, dict[str, Any]], Awaitable[Any]],
|
|
48
|
+
event: Update,
|
|
49
|
+
data: dict[str, Any],
|
|
50
|
+
) -> Any:
|
|
51
|
+
raw = event.model_dump(mode="json")
|
|
52
|
+
await self._client.forward_update(raw)
|
|
53
|
+
return await handler(event, data)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _OutgoingMiddleware(BaseMiddleware):
|
|
57
|
+
"""Outer middleware on ``Message`` (response) — catches bot replies."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, client: SteeperClient) -> None:
|
|
60
|
+
self._client = client
|
|
61
|
+
|
|
62
|
+
async def __call__(
|
|
63
|
+
self,
|
|
64
|
+
handler: Callable[[Message, dict[str, Any]], Awaitable[Any]],
|
|
65
|
+
event: Message,
|
|
66
|
+
data: dict[str, Any],
|
|
67
|
+
) -> Any:
|
|
68
|
+
result = await handler(event, data)
|
|
69
|
+
if isinstance(result, Message):
|
|
70
|
+
await self._client.log_bot_message(
|
|
71
|
+
chat_id=result.chat.id,
|
|
72
|
+
text=result.text or result.caption or "",
|
|
73
|
+
message_id=result.message_id,
|
|
74
|
+
date=int(result.date.timestamp()) if result.date else None,
|
|
75
|
+
)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _wrap_bot_send(bot: Bot, client: SteeperClient) -> None:
|
|
80
|
+
"""Monkey-patch ``Bot.send_message`` to also log to Steeper."""
|
|
81
|
+
_original_send = bot.send_message
|
|
82
|
+
|
|
83
|
+
async def _patched_send(*args: Any, **kwargs: Any) -> Message:
|
|
84
|
+
result: Message = await _original_send(*args, **kwargs)
|
|
85
|
+
try:
|
|
86
|
+
await client.log_bot_message(
|
|
87
|
+
chat_id=result.chat.id,
|
|
88
|
+
text=result.text or result.caption or "",
|
|
89
|
+
message_id=result.message_id,
|
|
90
|
+
date=int(result.date.timestamp()) if result.date else None,
|
|
91
|
+
)
|
|
92
|
+
except Exception:
|
|
93
|
+
logger.debug("Failed to log outgoing message", exc_info=True)
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
bot.send_message = _patched_send # type: ignore[assignment]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SteeperMiddleware:
|
|
100
|
+
"""All-in-one Steeper integration for aiogram v3.
|
|
101
|
+
|
|
102
|
+
Call :meth:`setup` to register both incoming and outgoing hooks.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
base_url: str,
|
|
108
|
+
bot_id: str,
|
|
109
|
+
bot_token: str,
|
|
110
|
+
*,
|
|
111
|
+
timeout: float = 10.0,
|
|
112
|
+
) -> None:
|
|
113
|
+
self._config = SteeperConfig(
|
|
114
|
+
base_url=base_url,
|
|
115
|
+
bot_id=bot_id,
|
|
116
|
+
bot_token=bot_token,
|
|
117
|
+
)
|
|
118
|
+
self._client = SteeperClient(self._config, timeout=timeout)
|
|
119
|
+
|
|
120
|
+
def setup(self, dp: Dispatcher, bot: Bot) -> None:
|
|
121
|
+
"""Register Steeper on the dispatcher and bot.
|
|
122
|
+
|
|
123
|
+
- Incoming updates are forwarded via an outer Update middleware.
|
|
124
|
+
- Outgoing ``send_message`` calls are patched to log bot replies.
|
|
125
|
+
"""
|
|
126
|
+
dp.update.outer_middleware(self._incoming)
|
|
127
|
+
_wrap_bot_send(bot, self._client)
|
|
128
|
+
logger.info("Steeper middleware registered for aiogram")
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def _incoming(self) -> _IncomingMiddleware:
|
|
132
|
+
return _IncomingMiddleware(self._client)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def client(self) -> SteeperClient:
|
|
136
|
+
return self._client
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Steeper middleware for **python-telegram-bot** (PTB v20+).
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from telegram.ext import ApplicationBuilder
|
|
6
|
+
from steeper.integrations.ptb import SteeperMiddleware
|
|
7
|
+
|
|
8
|
+
app = ApplicationBuilder().token(BOT_TOKEN).build()
|
|
9
|
+
|
|
10
|
+
steeper = SteeperMiddleware(
|
|
11
|
+
base_url="http://localhost:8000",
|
|
12
|
+
bot_id="<uuid>",
|
|
13
|
+
bot_token=BOT_TOKEN,
|
|
14
|
+
)
|
|
15
|
+
steeper.setup(app)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import time
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from steeper._client import SteeperClient
|
|
25
|
+
from steeper._config import SteeperConfig
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("steeper.ptb")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from telegram import Message, Update, User, Chat
|
|
31
|
+
from telegram.ext import (
|
|
32
|
+
Application,
|
|
33
|
+
BaseHandler,
|
|
34
|
+
ContextTypes,
|
|
35
|
+
)
|
|
36
|
+
except ImportError as _exc:
|
|
37
|
+
raise ImportError(
|
|
38
|
+
"python-telegram-bot>=20.0 is required for this integration. "
|
|
39
|
+
"Install it with: pip install steeper[ptb]"
|
|
40
|
+
) from _exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _update_to_dict(update: Update) -> dict[str, Any]:
|
|
44
|
+
"""Convert a PTB Update to a Telegram-compatible dict."""
|
|
45
|
+
raw: dict[str, Any] = {"update_id": update.update_id}
|
|
46
|
+
|
|
47
|
+
if update.message:
|
|
48
|
+
raw["message"] = _message_to_dict(update.message)
|
|
49
|
+
if update.edited_message:
|
|
50
|
+
raw["edited_message"] = _message_to_dict(update.edited_message)
|
|
51
|
+
return raw
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _user_to_dict(user: User) -> dict[str, Any]:
|
|
55
|
+
data: dict[str, Any] = {
|
|
56
|
+
"id": user.id,
|
|
57
|
+
"is_bot": user.is_bot,
|
|
58
|
+
"first_name": user.first_name,
|
|
59
|
+
}
|
|
60
|
+
if user.last_name:
|
|
61
|
+
data["last_name"] = user.last_name
|
|
62
|
+
if user.username:
|
|
63
|
+
data["username"] = user.username
|
|
64
|
+
if user.language_code:
|
|
65
|
+
data["language_code"] = user.language_code
|
|
66
|
+
return data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _chat_to_dict(chat: Chat) -> dict[str, Any]:
|
|
70
|
+
data: dict[str, Any] = {"id": chat.id, "type": chat.type}
|
|
71
|
+
if chat.title:
|
|
72
|
+
data["title"] = chat.title
|
|
73
|
+
if chat.username:
|
|
74
|
+
data["username"] = chat.username
|
|
75
|
+
if chat.first_name:
|
|
76
|
+
data["first_name"] = chat.first_name
|
|
77
|
+
if chat.last_name:
|
|
78
|
+
data["last_name"] = chat.last_name
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _message_to_dict(msg: Message) -> dict[str, Any]:
|
|
83
|
+
data: dict[str, Any] = {
|
|
84
|
+
"message_id": msg.message_id,
|
|
85
|
+
"chat": _chat_to_dict(msg.chat),
|
|
86
|
+
"date": int(msg.date.timestamp()) if msg.date else int(time.time()),
|
|
87
|
+
}
|
|
88
|
+
if msg.from_user:
|
|
89
|
+
data["from"] = _user_to_dict(msg.from_user)
|
|
90
|
+
if msg.text:
|
|
91
|
+
data["text"] = msg.text
|
|
92
|
+
if msg.caption:
|
|
93
|
+
data["caption"] = msg.caption
|
|
94
|
+
return data
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _SteeperHandler(BaseHandler[Update, ContextTypes.DEFAULT_TYPE]):
|
|
98
|
+
"""Low-priority handler that intercepts every update for Steeper logging."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, client: SteeperClient) -> None:
|
|
101
|
+
super().__init__(callback=self._noop)
|
|
102
|
+
self._client = client
|
|
103
|
+
|
|
104
|
+
def check_update(self, update: object) -> bool:
|
|
105
|
+
return isinstance(update, Update)
|
|
106
|
+
|
|
107
|
+
async def handle_update(
|
|
108
|
+
self,
|
|
109
|
+
update: Update,
|
|
110
|
+
application: Application, # type: ignore[type-arg]
|
|
111
|
+
check_result: Any,
|
|
112
|
+
context: ContextTypes.DEFAULT_TYPE,
|
|
113
|
+
) -> None:
|
|
114
|
+
raw = _update_to_dict(update)
|
|
115
|
+
await self._client.forward_update(raw)
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
async def _noop(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _wrap_bot_send(application: Application, client: SteeperClient) -> None: # type: ignore[type-arg]
|
|
123
|
+
"""Patch ``Bot.send_message`` to also log to Steeper."""
|
|
124
|
+
bot = application.bot
|
|
125
|
+
_original_send = bot.send_message
|
|
126
|
+
|
|
127
|
+
async def _patched_send(*args: Any, **kwargs: Any) -> Message:
|
|
128
|
+
result: Message = await _original_send(*args, **kwargs)
|
|
129
|
+
try:
|
|
130
|
+
await client.log_bot_message(
|
|
131
|
+
chat_id=result.chat.id,
|
|
132
|
+
text=result.text or result.caption or "",
|
|
133
|
+
message_id=result.message_id,
|
|
134
|
+
date=int(result.date.timestamp()) if result.date else None,
|
|
135
|
+
)
|
|
136
|
+
except Exception:
|
|
137
|
+
logger.debug("Failed to log outgoing message", exc_info=True)
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
bot.send_message = _patched_send # type: ignore[assignment]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SteeperMiddleware:
|
|
144
|
+
"""All-in-one Steeper integration for python-telegram-bot v20+.
|
|
145
|
+
|
|
146
|
+
Call :meth:`setup` to register both incoming and outgoing hooks.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
base_url: str,
|
|
152
|
+
bot_id: str,
|
|
153
|
+
bot_token: str,
|
|
154
|
+
*,
|
|
155
|
+
timeout: float = 10.0,
|
|
156
|
+
) -> None:
|
|
157
|
+
self._config = SteeperConfig(
|
|
158
|
+
base_url=base_url,
|
|
159
|
+
bot_id=bot_id,
|
|
160
|
+
bot_token=bot_token,
|
|
161
|
+
)
|
|
162
|
+
self._client = SteeperClient(self._config, timeout=timeout)
|
|
163
|
+
|
|
164
|
+
def setup(self, application: Application) -> None: # type: ignore[type-arg]
|
|
165
|
+
"""Register Steeper hooks on a PTB Application.
|
|
166
|
+
|
|
167
|
+
- Incoming: a low-priority handler that captures every Update.
|
|
168
|
+
- Outgoing: ``Bot.send_message`` is patched to log bot replies.
|
|
169
|
+
"""
|
|
170
|
+
application.add_handler(_SteeperHandler(self._client), group=-1)
|
|
171
|
+
_wrap_bot_send(application, self._client)
|
|
172
|
+
logger.info("Steeper middleware registered for python-telegram-bot")
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def client(self) -> SteeperClient:
|
|
176
|
+
return self._client
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Steeper middleware for **pyTelegramBotAPI** (telebot).
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
import telebot
|
|
6
|
+
from steeper.integrations.telebot import SteeperMiddleware
|
|
7
|
+
|
|
8
|
+
bot = telebot.TeleBot(BOT_TOKEN)
|
|
9
|
+
|
|
10
|
+
steeper = SteeperMiddleware(
|
|
11
|
+
base_url="http://localhost:8000",
|
|
12
|
+
bot_id="<uuid>",
|
|
13
|
+
bot_token=BOT_TOKEN,
|
|
14
|
+
)
|
|
15
|
+
steeper.setup(bot)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from steeper._client import SteeperClient
|
|
26
|
+
from steeper._config import SteeperConfig
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("steeper.telebot")
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import telebot as _telebot
|
|
32
|
+
from telebot import types as tg_types
|
|
33
|
+
except ImportError as _exc:
|
|
34
|
+
raise ImportError(
|
|
35
|
+
"pyTelegramBotAPI>=4.0 is required for this integration. "
|
|
36
|
+
"Install it with: pip install steeper[telebot]"
|
|
37
|
+
) from _exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_async(coro: Any) -> None:
|
|
41
|
+
"""Fire-and-forget an async coroutine from sync context."""
|
|
42
|
+
try:
|
|
43
|
+
loop = asyncio.get_running_loop()
|
|
44
|
+
loop.create_task(coro)
|
|
45
|
+
except RuntimeError:
|
|
46
|
+
asyncio.run(coro)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _update_to_dict(update: tg_types.Update) -> dict[str, Any]:
|
|
50
|
+
"""Convert a telebot Update to a Telegram-compatible dict."""
|
|
51
|
+
raw: dict[str, Any] = {"update_id": update.update_id}
|
|
52
|
+
|
|
53
|
+
if update.message:
|
|
54
|
+
raw["message"] = _message_to_dict(update.message)
|
|
55
|
+
if update.edited_message:
|
|
56
|
+
raw["edited_message"] = _message_to_dict(update.edited_message)
|
|
57
|
+
return raw
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _message_to_dict(msg: tg_types.Message) -> dict[str, Any]:
|
|
61
|
+
"""Convert a telebot Message to a Telegram-compatible dict."""
|
|
62
|
+
data: dict[str, Any] = {
|
|
63
|
+
"message_id": msg.message_id,
|
|
64
|
+
"chat": {"id": msg.chat.id, "type": msg.chat.type},
|
|
65
|
+
"date": msg.date or int(time.time()),
|
|
66
|
+
}
|
|
67
|
+
if msg.chat.title:
|
|
68
|
+
data["chat"]["title"] = msg.chat.title
|
|
69
|
+
if msg.chat.username:
|
|
70
|
+
data["chat"]["username"] = msg.chat.username
|
|
71
|
+
if msg.chat.first_name:
|
|
72
|
+
data["chat"]["first_name"] = msg.chat.first_name
|
|
73
|
+
if msg.chat.last_name:
|
|
74
|
+
data["chat"]["last_name"] = msg.chat.last_name
|
|
75
|
+
|
|
76
|
+
if msg.from_user:
|
|
77
|
+
data["from"] = {
|
|
78
|
+
"id": msg.from_user.id,
|
|
79
|
+
"is_bot": msg.from_user.is_bot,
|
|
80
|
+
"first_name": msg.from_user.first_name,
|
|
81
|
+
}
|
|
82
|
+
if msg.from_user.last_name:
|
|
83
|
+
data["from"]["last_name"] = msg.from_user.last_name
|
|
84
|
+
if msg.from_user.username:
|
|
85
|
+
data["from"]["username"] = msg.from_user.username
|
|
86
|
+
if msg.from_user.language_code:
|
|
87
|
+
data["from"]["language_code"] = msg.from_user.language_code
|
|
88
|
+
|
|
89
|
+
if msg.text:
|
|
90
|
+
data["text"] = msg.text
|
|
91
|
+
if msg.caption:
|
|
92
|
+
data["caption"] = msg.caption
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SteeperMiddleware:
|
|
97
|
+
"""All-in-one Steeper integration for pyTelegramBotAPI.
|
|
98
|
+
|
|
99
|
+
Call :meth:`setup` to register both incoming and outgoing hooks.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
base_url: str,
|
|
105
|
+
bot_id: str,
|
|
106
|
+
bot_token: str,
|
|
107
|
+
*,
|
|
108
|
+
timeout: float = 10.0,
|
|
109
|
+
) -> None:
|
|
110
|
+
self._config = SteeperConfig(
|
|
111
|
+
base_url=base_url,
|
|
112
|
+
bot_id=bot_id,
|
|
113
|
+
bot_token=bot_token,
|
|
114
|
+
)
|
|
115
|
+
self._client = SteeperClient(self._config, timeout=timeout)
|
|
116
|
+
|
|
117
|
+
def setup(self, bot: _telebot.TeleBot) -> None:
|
|
118
|
+
"""Register Steeper hooks on a sync TeleBot instance.
|
|
119
|
+
|
|
120
|
+
- Incoming: ``middleware_handler`` that fires on every update.
|
|
121
|
+
- Outgoing: ``send_message`` is patched to log bot replies.
|
|
122
|
+
"""
|
|
123
|
+
bot.use_class_middlewares = True
|
|
124
|
+
|
|
125
|
+
class _Incoming(_telebot.handler_backends.BaseMiddleware):
|
|
126
|
+
update_types = ["message", "edited_message"]
|
|
127
|
+
|
|
128
|
+
def __init__(self_inner) -> None: # noqa: N805
|
|
129
|
+
super().__init__()
|
|
130
|
+
self_inner.client = self._client
|
|
131
|
+
|
|
132
|
+
def pre_process(self_inner, message: tg_types.Message, data: dict[str, Any]) -> None: # noqa: N805
|
|
133
|
+
update_dict: dict[str, Any] = {
|
|
134
|
+
"update_id": 0,
|
|
135
|
+
"message": _message_to_dict(message),
|
|
136
|
+
}
|
|
137
|
+
_run_async(self_inner.client.forward_update(update_dict))
|
|
138
|
+
|
|
139
|
+
def post_process(self_inner, message: tg_types.Message, data: dict[str, Any], exception: BaseException | None) -> None: # noqa: N805
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
bot.register_middleware_handler(_Incoming())
|
|
143
|
+
|
|
144
|
+
_original_send = bot.send_message
|
|
145
|
+
|
|
146
|
+
def _patched_send(*args: Any, **kwargs: Any) -> tg_types.Message:
|
|
147
|
+
result: tg_types.Message = _original_send(*args, **kwargs)
|
|
148
|
+
try:
|
|
149
|
+
_run_async(
|
|
150
|
+
self._client.log_bot_message(
|
|
151
|
+
chat_id=result.chat.id,
|
|
152
|
+
text=result.text or result.caption or "",
|
|
153
|
+
message_id=result.message_id,
|
|
154
|
+
date=result.date or int(time.time()),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
except Exception:
|
|
158
|
+
logger.debug("Failed to log outgoing message", exc_info=True)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
bot.send_message = _patched_send # type: ignore[assignment]
|
|
162
|
+
|
|
163
|
+
logger.info("Steeper middleware registered for pyTelegramBotAPI")
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def client(self) -> SteeperClient:
|
|
167
|
+
return self._client
|
steeper/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: steeper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Telegram bot middleware for Steeper — intercepts updates and outgoing messages to sync with the Steeper platform.
|
|
5
|
+
Project-URL: Homepage, https://github.com/steeper/steeper
|
|
6
|
+
Project-URL: Repository, https://github.com/steeper/steeper
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: aiogram,bot,middleware,python-telegram-bot,steeper,telebot,telegram
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Framework :: AsyncIO
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Communications :: Chat
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx>=0.25
|
|
22
|
+
Provides-Extra: aiogram
|
|
23
|
+
Requires-Dist: aiogram>=3.0; extra == 'aiogram'
|
|
24
|
+
Provides-Extra: all
|
|
25
|
+
Requires-Dist: aiogram>=3.0; extra == 'all'
|
|
26
|
+
Requires-Dist: pytelegrambotapi>=4.0; extra == 'all'
|
|
27
|
+
Requires-Dist: python-telegram-bot>=20.0; extra == 'all'
|
|
28
|
+
Provides-Extra: ptb
|
|
29
|
+
Requires-Dist: python-telegram-bot>=20.0; extra == 'ptb'
|
|
30
|
+
Provides-Extra: telebot
|
|
31
|
+
Requires-Dist: pytelegrambotapi>=4.0; extra == 'telebot'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# Steeper
|
|
35
|
+
|
|
36
|
+
Telegram bot middleware that syncs incoming user messages and outgoing bot replies with the **Steeper** platform.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Core (pick one extra for your framework)
|
|
42
|
+
pip install steeper[aiogram] # aiogram v3
|
|
43
|
+
pip install steeper[telebot] # pyTelegramBotAPI
|
|
44
|
+
pip install steeper[ptb] # python-telegram-bot v20+
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
Every integration requires three values:
|
|
50
|
+
|
|
51
|
+
| Parameter | Description |
|
|
52
|
+
|-------------|----------------------------------------------|
|
|
53
|
+
| `base_url` | Steeper backend URL (e.g. `http://localhost:8000`) |
|
|
54
|
+
| `bot_id` | UUID of the bot registered in Steeper |
|
|
55
|
+
| `bot_token` | Raw Telegram bot token from BotFather |
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
### aiogram v3
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from aiogram import Bot, Dispatcher
|
|
63
|
+
from steeper.integrations.aiogram import SteeperMiddleware
|
|
64
|
+
|
|
65
|
+
BOT_TOKEN = "123456:ABC-DEF..."
|
|
66
|
+
bot = Bot(token=BOT_TOKEN)
|
|
67
|
+
dp = Dispatcher()
|
|
68
|
+
|
|
69
|
+
steeper = SteeperMiddleware(
|
|
70
|
+
base_url="http://localhost:8000",
|
|
71
|
+
bot_id="your-bot-uuid",
|
|
72
|
+
bot_token=BOT_TOKEN,
|
|
73
|
+
)
|
|
74
|
+
steeper.setup(dp, bot)
|
|
75
|
+
|
|
76
|
+
# ... register your handlers as usual ...
|
|
77
|
+
dp.run_polling(bot)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### pyTelegramBotAPI (telebot)
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import telebot
|
|
84
|
+
from steeper.integrations.telebot import SteeperMiddleware
|
|
85
|
+
|
|
86
|
+
BOT_TOKEN = "123456:ABC-DEF..."
|
|
87
|
+
bot = telebot.TeleBot(BOT_TOKEN)
|
|
88
|
+
|
|
89
|
+
steeper = SteeperMiddleware(
|
|
90
|
+
base_url="http://localhost:8000",
|
|
91
|
+
bot_id="your-bot-uuid",
|
|
92
|
+
bot_token=BOT_TOKEN,
|
|
93
|
+
)
|
|
94
|
+
steeper.setup(bot)
|
|
95
|
+
|
|
96
|
+
# ... register your handlers as usual ...
|
|
97
|
+
bot.polling()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### python-telegram-bot v20+
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from telegram.ext import ApplicationBuilder
|
|
104
|
+
from steeper.integrations.ptb import SteeperMiddleware
|
|
105
|
+
|
|
106
|
+
BOT_TOKEN = "123456:ABC-DEF..."
|
|
107
|
+
app = ApplicationBuilder().token(BOT_TOKEN).build()
|
|
108
|
+
|
|
109
|
+
steeper = SteeperMiddleware(
|
|
110
|
+
base_url="http://localhost:8000",
|
|
111
|
+
bot_id="your-bot-uuid",
|
|
112
|
+
bot_token=BOT_TOKEN,
|
|
113
|
+
)
|
|
114
|
+
steeper.setup(app)
|
|
115
|
+
|
|
116
|
+
# ... register your handlers as usual ...
|
|
117
|
+
app.run_polling()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## How it works
|
|
121
|
+
|
|
122
|
+
1. **Incoming messages** — the middleware intercepts every Telegram Update, serializes it to the standard Telegram JSON format, and POSTs it to the Steeper webhook endpoint. Your handlers still run normally.
|
|
123
|
+
|
|
124
|
+
2. **Outgoing messages** — `Bot.send_message` is patched so that every bot reply is also logged to the Steeper bot-message endpoint.
|
|
125
|
+
|
|
126
|
+
Both operations are fire-and-forget: if the Steeper backend is unreachable, a warning is logged but your bot continues to work.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
steeper/__init__.py,sha256=lf6SiHgcInqYbkhHmqUskqlS3VJvjdd-2vqFd9nSefc,462
|
|
2
|
+
steeper/_client.py,sha256=4ElOj0SVFnCFU98W0s_FJgzj95fl1g9kgFF46bb-W2k,1756
|
|
3
|
+
steeper/_config.py,sha256=GKWDYbIB_t_S4erWjwv9Swmvvk44jW0L9rjzr68tIDg,987
|
|
4
|
+
steeper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
steeper/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
steeper/integrations/aiogram.py,sha256=P7YsQPDtltkSNoV95O5KT4nDhb4GZbChPHxOYhk5_WE,4109
|
|
7
|
+
steeper/integrations/ptb.py,sha256=DB0lyq1wEeO3aN_gZe31sj_NOX0CpxgK0nHqmdkAA70,5313
|
|
8
|
+
steeper/integrations/telebot.py,sha256=YI_cWHS8lbLldfIdjPDDSJ4yhm9s-1IfV_ad6aI10CQ,5319
|
|
9
|
+
steeper-0.1.0.dist-info/METADATA,sha256=EHGee0FWIavTQ5PurY9x1BLcVYEl1g36X9gk5lm9p2M,3877
|
|
10
|
+
steeper-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
steeper-0.1.0.dist-info/RECORD,,
|