chatops-bridge 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chatops_bridge-0.1.0/LICENSE +21 -0
- chatops_bridge-0.1.0/PKG-INFO +261 -0
- chatops_bridge-0.1.0/README.md +232 -0
- chatops_bridge-0.1.0/pyproject.toml +47 -0
- chatops_bridge-0.1.0/setup.cfg +4 -0
- chatops_bridge-0.1.0/src/chatops_bridge/__init__.py +23 -0
- chatops_bridge-0.1.0/src/chatops_bridge/discord.py +3 -0
- chatops_bridge-0.1.0/src/chatops_bridge/discord_bot.py +183 -0
- chatops_bridge-0.1.0/src/chatops_bridge/discord_channels.py +23 -0
- chatops_bridge-0.1.0/src/chatops_bridge/telegram.py +8 -0
- chatops_bridge-0.1.0/src/chatops_bridge/telegram_commands.py +18 -0
- chatops_bridge-0.1.0/src/chatops_bridge/telegram_format.py +39 -0
- chatops_bridge-0.1.0/src/chatops_bridge/telegram_poller.py +210 -0
- chatops_bridge-0.1.0/src/chatops_bridge.egg-info/PKG-INFO +261 -0
- chatops_bridge-0.1.0/src/chatops_bridge.egg-info/SOURCES.txt +16 -0
- chatops_bridge-0.1.0/src/chatops_bridge.egg-info/dependency_links.txt +1 -0
- chatops_bridge-0.1.0/src/chatops_bridge.egg-info/requires.txt +4 -0
- chatops_bridge-0.1.0/src/chatops_bridge.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ChatOps Bridge Contributors
|
|
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.
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chatops-bridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable Telegram and Discord ChatOps utilities for Python projects
|
|
5
|
+
Author: ChatOps Bridge Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/trunghvbk-afataw/chatops
|
|
8
|
+
Project-URL: Repository, https://github.com/trunghvbk-afataw/chatops
|
|
9
|
+
Project-URL: Issues, https://github.com/trunghvbk-afataw/chatops/issues
|
|
10
|
+
Keywords: telegram,discord,webhook,chatops,notifications
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Communications :: Chat
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: awesome-notify-bridge>=0.1.5
|
|
26
|
+
Provides-Extra: discord-bot
|
|
27
|
+
Requires-Dist: discord.py>=2.4.0; extra == "discord-bot"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# chatops-bridge
|
|
31
|
+
|
|
32
|
+
Reusable Telegram and Discord utilities for Python applications.
|
|
33
|
+
|
|
34
|
+
This package was extracted from production automation code and generalized for multi-purpose ChatOps use.
|
|
35
|
+
Transport-level Telegram/Discord sending is reused from `awesome-notify-bridge`.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- Telegram send/poll helpers:
|
|
40
|
+
- send text and images
|
|
41
|
+
- split long text by Telegram limit
|
|
42
|
+
- poll updates and extract message text
|
|
43
|
+
- robust chat id matching
|
|
44
|
+
- Discord webhook sender:
|
|
45
|
+
- send message chunks safely
|
|
46
|
+
- upload image files
|
|
47
|
+
- Discord slash bot:
|
|
48
|
+
- register any slash commands dynamically
|
|
49
|
+
- optional owner and guild restriction
|
|
50
|
+
- Plain text formatter helpers for chat-friendly output
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
Requires Python 3.9+.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install chatops-bridge
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For slash-bot support:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install "chatops-bridge[discord-bot]"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Install from GitHub:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install git+https://github.com/<your-org>/chatops-bridge.git
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick Start
|
|
73
|
+
|
|
74
|
+
### Telegram
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from chatops_bridge.telegram import send_telegram_message
|
|
78
|
+
|
|
79
|
+
send_telegram_message(
|
|
80
|
+
chat_id="123456789",
|
|
81
|
+
token="<telegram-bot-token>",
|
|
82
|
+
text="Hello from chatops-bridge",
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Send with images:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
send_telegram_message(
|
|
90
|
+
chat_id="123456789",
|
|
91
|
+
token="<telegram-bot-token>",
|
|
92
|
+
text="Report attached",
|
|
93
|
+
image_paths=["./charts/btc.png", "./charts/eth.png"],
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Discord webhook
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from chatops_bridge.discord import send_discord_message
|
|
101
|
+
|
|
102
|
+
send_discord_message(
|
|
103
|
+
webhook_url="https://discord.com/api/webhooks/...",
|
|
104
|
+
content="Hello from chatops-bridge",
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Return value is `True` on success and `False` on failure.
|
|
109
|
+
|
|
110
|
+
### Discord env-based channel
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from chatops_bridge.discord_channels import send_to_discord_env_channel
|
|
114
|
+
|
|
115
|
+
# export DISCORD_ALERT_WEBHOOK_URL=...
|
|
116
|
+
send_to_discord_env_channel("DISCORD_ALERT_WEBHOOK_URL", "Alert triggered")
|
|
117
|
+
|
|
118
|
+
# strict mode: fail fast if env var is missing
|
|
119
|
+
send_to_discord_env_channel("DISCORD_ALERT_WEBHOOK_URL", "Alert triggered", strict_env=True)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Useful when you want channel routing from environment variables instead of hardcoding webhook URLs.
|
|
123
|
+
|
|
124
|
+
## Telegram Update Polling
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from chatops_bridge.telegram import fetch_telegram_updates, extract_update_text
|
|
128
|
+
|
|
129
|
+
updates = fetch_telegram_updates(
|
|
130
|
+
token="<telegram-bot-token>",
|
|
131
|
+
offset=None,
|
|
132
|
+
timeout_seconds=10,
|
|
133
|
+
allowed_updates=["message", "edited_message"],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
for update in updates:
|
|
137
|
+
text, message = extract_update_text(update)
|
|
138
|
+
if text:
|
|
139
|
+
print("message:", text)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Discord Slash Bot
|
|
143
|
+
|
|
144
|
+
`chatops_bridge.discord_bot.start_discord_bot(...)` starts a background thread for a Discord bot using your command list.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
import discord
|
|
148
|
+
|
|
149
|
+
from chatops_bridge.discord_bot import DiscordBotConfig, SlashCommandSpec, start_discord_bot
|
|
150
|
+
|
|
151
|
+
def ping() -> str:
|
|
152
|
+
return "pong"
|
|
153
|
+
|
|
154
|
+
def report() -> dict:
|
|
155
|
+
return {"text": "Report done", "image_paths": ["./out/chart.png"]}
|
|
156
|
+
|
|
157
|
+
start_discord_bot(
|
|
158
|
+
token="<discord-bot-token>",
|
|
159
|
+
commands=[
|
|
160
|
+
SlashCommandSpec(name="ping", description="Health check", handler=ping),
|
|
161
|
+
SlashCommandSpec(name="report", description="Generate report", handler=report),
|
|
162
|
+
],
|
|
163
|
+
config=DiscordBotConfig(
|
|
164
|
+
intents=discord.Intents(guilds=True),
|
|
165
|
+
allow_dm_commands=False,
|
|
166
|
+
leave_unexpected_guild=True,
|
|
167
|
+
response_chunk_limit=1900,
|
|
168
|
+
max_images_per_response=10,
|
|
169
|
+
defer_thinking=True,
|
|
170
|
+
),
|
|
171
|
+
guild_id=None,
|
|
172
|
+
owner_user_id=None,
|
|
173
|
+
instance_key="primary",
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
You can also run the bot with `commands=[]` (or omit `commands`) when your app only needs Discord client events/background tasks.
|
|
178
|
+
|
|
179
|
+
`instance_key` is used to manage bot thread instances by key. Calling `start_discord_bot` again with the same key returns the existing live thread.
|
|
180
|
+
|
|
181
|
+
## Slash Bot Example
|
|
182
|
+
|
|
183
|
+
See [examples/discord_bot_example.py](examples/discord_bot_example.py).
|
|
184
|
+
|
|
185
|
+
## Telegram Poller Bot
|
|
186
|
+
|
|
187
|
+
`chatops_bridge.telegram_poller.start_telegram_poller(...)` starts a background polling thread that automatically handles Telegram updates and dispatches them to your command handlers.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from chatops_bridge.telegram_poller import TelegramCommandSpec, TelegramPollerConfig, start_telegram_poller
|
|
191
|
+
|
|
192
|
+
def handle_status(args: list[str]) -> str:
|
|
193
|
+
return "Status: OK"
|
|
194
|
+
|
|
195
|
+
def handle_trade(args: list[str]) -> str:
|
|
196
|
+
amount = float(args[0]) if args else 100.0
|
|
197
|
+
return f"Trading {amount} units"
|
|
198
|
+
|
|
199
|
+
start_telegram_poller(
|
|
200
|
+
token="<telegram-bot-token>",
|
|
201
|
+
commands=[
|
|
202
|
+
TelegramCommandSpec(
|
|
203
|
+
name="status",
|
|
204
|
+
description="Get bot status",
|
|
205
|
+
handler=handle_status,
|
|
206
|
+
),
|
|
207
|
+
TelegramCommandSpec(
|
|
208
|
+
name="trade",
|
|
209
|
+
description="Execute trade",
|
|
210
|
+
handler=handle_trade,
|
|
211
|
+
),
|
|
212
|
+
],
|
|
213
|
+
config=TelegramPollerConfig(
|
|
214
|
+
poll_timeout_seconds=30,
|
|
215
|
+
max_retries=3,
|
|
216
|
+
retry_delay_seconds=5,
|
|
217
|
+
allowed_updates=["message", "edited_message"],
|
|
218
|
+
offset_file="/tmp/telegram_offset.txt", # Persist offset on restart
|
|
219
|
+
),
|
|
220
|
+
logger=print, # Optional logger
|
|
221
|
+
)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The poller:
|
|
225
|
+
- Runs in background thread(s)
|
|
226
|
+
- Automatically saves offset to persist progress across restarts
|
|
227
|
+
- Retries on network errors with configurable backoff
|
|
228
|
+
- Filters update types (e.g., messages only, skip reactions)
|
|
229
|
+
- Parses `/command arg1 arg2` style messages
|
|
230
|
+
- Dispatches to appropriate handler
|
|
231
|
+
- Sends response back to the originating chat
|
|
232
|
+
|
|
233
|
+
Handlers receive a list of arguments and return `str` or `dict` (JSON-serialized if dict).
|
|
234
|
+
|
|
235
|
+
## Telegram Poller Example
|
|
236
|
+
|
|
237
|
+
See [examples/telegram_poller_example.py](examples/telegram_poller_example.py).
|
|
238
|
+
|
|
239
|
+
## Common Environment Variables
|
|
240
|
+
|
|
241
|
+
- `DISCORD_BOT_TOKEN`: bot token used by the slash-bot example.
|
|
242
|
+
- `DISCORD_BOT_GUILD_ID`: optional guild restriction.
|
|
243
|
+
- `DISCORD_BOT_OWNER_USER_ID`: optional owner-only restriction.
|
|
244
|
+
- `TELEGRAM_BOT_TOKEN`: polling bot token.
|
|
245
|
+
- `TELEGRAM_BOT_OFFSET_FILE`: optional path to persist polling offset (for restart safety).
|
|
246
|
+
- Custom webhook URL variables (for env-based routing), for example:
|
|
247
|
+
- `DISCORD_ALERT_WEBHOOK_URL`
|
|
248
|
+
- `DISCORD_SIGNAL_WEBHOOK_URL`
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
MIT
|
|
253
|
+
|
|
254
|
+
## Contributing
|
|
255
|
+
|
|
256
|
+
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
|
257
|
+
- Development setup
|
|
258
|
+
- Making changes and submitting PRs
|
|
259
|
+
- Release procedures
|
|
260
|
+
|
|
261
|
+
For questions, open an issue on [GitHub](https://github.com/trunghvbk-afataw/chatops/issues).
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# chatops-bridge
|
|
2
|
+
|
|
3
|
+
Reusable Telegram and Discord utilities for Python applications.
|
|
4
|
+
|
|
5
|
+
This package was extracted from production automation code and generalized for multi-purpose ChatOps use.
|
|
6
|
+
Transport-level Telegram/Discord sending is reused from `awesome-notify-bridge`.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- Telegram send/poll helpers:
|
|
11
|
+
- send text and images
|
|
12
|
+
- split long text by Telegram limit
|
|
13
|
+
- poll updates and extract message text
|
|
14
|
+
- robust chat id matching
|
|
15
|
+
- Discord webhook sender:
|
|
16
|
+
- send message chunks safely
|
|
17
|
+
- upload image files
|
|
18
|
+
- Discord slash bot:
|
|
19
|
+
- register any slash commands dynamically
|
|
20
|
+
- optional owner and guild restriction
|
|
21
|
+
- Plain text formatter helpers for chat-friendly output
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Requires Python 3.9+.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install chatops-bridge
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For slash-bot support:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install "chatops-bridge[discord-bot]"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Install from GitHub:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install git+https://github.com/<your-org>/chatops-bridge.git
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
### Telegram
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from chatops_bridge.telegram import send_telegram_message
|
|
49
|
+
|
|
50
|
+
send_telegram_message(
|
|
51
|
+
chat_id="123456789",
|
|
52
|
+
token="<telegram-bot-token>",
|
|
53
|
+
text="Hello from chatops-bridge",
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Send with images:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
send_telegram_message(
|
|
61
|
+
chat_id="123456789",
|
|
62
|
+
token="<telegram-bot-token>",
|
|
63
|
+
text="Report attached",
|
|
64
|
+
image_paths=["./charts/btc.png", "./charts/eth.png"],
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Discord webhook
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from chatops_bridge.discord import send_discord_message
|
|
72
|
+
|
|
73
|
+
send_discord_message(
|
|
74
|
+
webhook_url="https://discord.com/api/webhooks/...",
|
|
75
|
+
content="Hello from chatops-bridge",
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Return value is `True` on success and `False` on failure.
|
|
80
|
+
|
|
81
|
+
### Discord env-based channel
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from chatops_bridge.discord_channels import send_to_discord_env_channel
|
|
85
|
+
|
|
86
|
+
# export DISCORD_ALERT_WEBHOOK_URL=...
|
|
87
|
+
send_to_discord_env_channel("DISCORD_ALERT_WEBHOOK_URL", "Alert triggered")
|
|
88
|
+
|
|
89
|
+
# strict mode: fail fast if env var is missing
|
|
90
|
+
send_to_discord_env_channel("DISCORD_ALERT_WEBHOOK_URL", "Alert triggered", strict_env=True)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Useful when you want channel routing from environment variables instead of hardcoding webhook URLs.
|
|
94
|
+
|
|
95
|
+
## Telegram Update Polling
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from chatops_bridge.telegram import fetch_telegram_updates, extract_update_text
|
|
99
|
+
|
|
100
|
+
updates = fetch_telegram_updates(
|
|
101
|
+
token="<telegram-bot-token>",
|
|
102
|
+
offset=None,
|
|
103
|
+
timeout_seconds=10,
|
|
104
|
+
allowed_updates=["message", "edited_message"],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for update in updates:
|
|
108
|
+
text, message = extract_update_text(update)
|
|
109
|
+
if text:
|
|
110
|
+
print("message:", text)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Discord Slash Bot
|
|
114
|
+
|
|
115
|
+
`chatops_bridge.discord_bot.start_discord_bot(...)` starts a background thread for a Discord bot using your command list.
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
import discord
|
|
119
|
+
|
|
120
|
+
from chatops_bridge.discord_bot import DiscordBotConfig, SlashCommandSpec, start_discord_bot
|
|
121
|
+
|
|
122
|
+
def ping() -> str:
|
|
123
|
+
return "pong"
|
|
124
|
+
|
|
125
|
+
def report() -> dict:
|
|
126
|
+
return {"text": "Report done", "image_paths": ["./out/chart.png"]}
|
|
127
|
+
|
|
128
|
+
start_discord_bot(
|
|
129
|
+
token="<discord-bot-token>",
|
|
130
|
+
commands=[
|
|
131
|
+
SlashCommandSpec(name="ping", description="Health check", handler=ping),
|
|
132
|
+
SlashCommandSpec(name="report", description="Generate report", handler=report),
|
|
133
|
+
],
|
|
134
|
+
config=DiscordBotConfig(
|
|
135
|
+
intents=discord.Intents(guilds=True),
|
|
136
|
+
allow_dm_commands=False,
|
|
137
|
+
leave_unexpected_guild=True,
|
|
138
|
+
response_chunk_limit=1900,
|
|
139
|
+
max_images_per_response=10,
|
|
140
|
+
defer_thinking=True,
|
|
141
|
+
),
|
|
142
|
+
guild_id=None,
|
|
143
|
+
owner_user_id=None,
|
|
144
|
+
instance_key="primary",
|
|
145
|
+
)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
You can also run the bot with `commands=[]` (or omit `commands`) when your app only needs Discord client events/background tasks.
|
|
149
|
+
|
|
150
|
+
`instance_key` is used to manage bot thread instances by key. Calling `start_discord_bot` again with the same key returns the existing live thread.
|
|
151
|
+
|
|
152
|
+
## Slash Bot Example
|
|
153
|
+
|
|
154
|
+
See [examples/discord_bot_example.py](examples/discord_bot_example.py).
|
|
155
|
+
|
|
156
|
+
## Telegram Poller Bot
|
|
157
|
+
|
|
158
|
+
`chatops_bridge.telegram_poller.start_telegram_poller(...)` starts a background polling thread that automatically handles Telegram updates and dispatches them to your command handlers.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from chatops_bridge.telegram_poller import TelegramCommandSpec, TelegramPollerConfig, start_telegram_poller
|
|
162
|
+
|
|
163
|
+
def handle_status(args: list[str]) -> str:
|
|
164
|
+
return "Status: OK"
|
|
165
|
+
|
|
166
|
+
def handle_trade(args: list[str]) -> str:
|
|
167
|
+
amount = float(args[0]) if args else 100.0
|
|
168
|
+
return f"Trading {amount} units"
|
|
169
|
+
|
|
170
|
+
start_telegram_poller(
|
|
171
|
+
token="<telegram-bot-token>",
|
|
172
|
+
commands=[
|
|
173
|
+
TelegramCommandSpec(
|
|
174
|
+
name="status",
|
|
175
|
+
description="Get bot status",
|
|
176
|
+
handler=handle_status,
|
|
177
|
+
),
|
|
178
|
+
TelegramCommandSpec(
|
|
179
|
+
name="trade",
|
|
180
|
+
description="Execute trade",
|
|
181
|
+
handler=handle_trade,
|
|
182
|
+
),
|
|
183
|
+
],
|
|
184
|
+
config=TelegramPollerConfig(
|
|
185
|
+
poll_timeout_seconds=30,
|
|
186
|
+
max_retries=3,
|
|
187
|
+
retry_delay_seconds=5,
|
|
188
|
+
allowed_updates=["message", "edited_message"],
|
|
189
|
+
offset_file="/tmp/telegram_offset.txt", # Persist offset on restart
|
|
190
|
+
),
|
|
191
|
+
logger=print, # Optional logger
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The poller:
|
|
196
|
+
- Runs in background thread(s)
|
|
197
|
+
- Automatically saves offset to persist progress across restarts
|
|
198
|
+
- Retries on network errors with configurable backoff
|
|
199
|
+
- Filters update types (e.g., messages only, skip reactions)
|
|
200
|
+
- Parses `/command arg1 arg2` style messages
|
|
201
|
+
- Dispatches to appropriate handler
|
|
202
|
+
- Sends response back to the originating chat
|
|
203
|
+
|
|
204
|
+
Handlers receive a list of arguments and return `str` or `dict` (JSON-serialized if dict).
|
|
205
|
+
|
|
206
|
+
## Telegram Poller Example
|
|
207
|
+
|
|
208
|
+
See [examples/telegram_poller_example.py](examples/telegram_poller_example.py).
|
|
209
|
+
|
|
210
|
+
## Common Environment Variables
|
|
211
|
+
|
|
212
|
+
- `DISCORD_BOT_TOKEN`: bot token used by the slash-bot example.
|
|
213
|
+
- `DISCORD_BOT_GUILD_ID`: optional guild restriction.
|
|
214
|
+
- `DISCORD_BOT_OWNER_USER_ID`: optional owner-only restriction.
|
|
215
|
+
- `TELEGRAM_BOT_TOKEN`: polling bot token.
|
|
216
|
+
- `TELEGRAM_BOT_OFFSET_FILE`: optional path to persist polling offset (for restart safety).
|
|
217
|
+
- Custom webhook URL variables (for env-based routing), for example:
|
|
218
|
+
- `DISCORD_ALERT_WEBHOOK_URL`
|
|
219
|
+
- `DISCORD_SIGNAL_WEBHOOK_URL`
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT
|
|
224
|
+
|
|
225
|
+
## Contributing
|
|
226
|
+
|
|
227
|
+
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
|
228
|
+
- Development setup
|
|
229
|
+
- Making changes and submitting PRs
|
|
230
|
+
- Release procedures
|
|
231
|
+
|
|
232
|
+
For questions, open an issue on [GitHub](https://github.com/trunghvbk-afataw/chatops/issues).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chatops-bridge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Reusable Telegram and Discord ChatOps utilities for Python projects"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "ChatOps Bridge Contributors" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["telegram", "discord", "webhook", "chatops", "notifications"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Communications :: Chat",
|
|
27
|
+
"Topic :: Software Development :: Libraries"
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"awesome-notify-bridge>=0.1.5"
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
discord-bot = [
|
|
35
|
+
"discord.py>=2.4.0"
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/trunghvbk-afataw/chatops"
|
|
40
|
+
Repository = "https://github.com/trunghvbk-afataw/chatops"
|
|
41
|
+
Issues = "https://github.com/trunghvbk-afataw/chatops/issues"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools]
|
|
44
|
+
package-dir = {"" = "src"}
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""chatops_bridge public API."""
|
|
2
|
+
|
|
3
|
+
from .discord import send_discord_message
|
|
4
|
+
from .discord_channels import send_to_discord_env_channel
|
|
5
|
+
from .discord_bot import DiscordBotConfig, SlashCommandSpec
|
|
6
|
+
from .telegram import chat_id_matches, extract_update_text, fetch_telegram_updates, send_telegram_message
|
|
7
|
+
from .telegram_commands import parse_telegram_command
|
|
8
|
+
from .telegram_poller import TelegramCommandSpec, TelegramPollerConfig, start_telegram_poller
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"chat_id_matches",
|
|
12
|
+
"extract_update_text",
|
|
13
|
+
"fetch_telegram_updates",
|
|
14
|
+
"parse_telegram_command",
|
|
15
|
+
"DiscordBotConfig",
|
|
16
|
+
"SlashCommandSpec",
|
|
17
|
+
"TelegramCommandSpec",
|
|
18
|
+
"TelegramPollerConfig",
|
|
19
|
+
"send_discord_message",
|
|
20
|
+
"send_telegram_message",
|
|
21
|
+
"send_to_discord_env_channel",
|
|
22
|
+
"start_telegram_poller",
|
|
23
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, Union
|
|
8
|
+
|
|
9
|
+
import discord
|
|
10
|
+
from discord import app_commands
|
|
11
|
+
|
|
12
|
+
ResponseHandler = Callable[[], Union[str, dict]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SlashCommandSpec:
|
|
17
|
+
name: str
|
|
18
|
+
description: str
|
|
19
|
+
handler: ResponseHandler
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DiscordBotConfig:
|
|
23
|
+
intents: discord.Intents | None = None
|
|
24
|
+
allow_dm_commands: bool = False
|
|
25
|
+
leave_unexpected_guild: bool = True
|
|
26
|
+
response_chunk_limit: int = 1900
|
|
27
|
+
max_images_per_response: int = 10
|
|
28
|
+
defer_thinking: bool = True
|
|
29
|
+
thread_name_prefix: str = "discord-bot"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_BOT_THREADS: dict[str, threading.Thread] = {}
|
|
33
|
+
_BOT_LOCK = threading.Lock()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _split_response_text(text: str, limit: int = 1900) -> list[str]:
|
|
37
|
+
if len(text) <= limit:
|
|
38
|
+
return [text]
|
|
39
|
+
chunks: list[str] = []
|
|
40
|
+
while text:
|
|
41
|
+
if len(text) <= limit:
|
|
42
|
+
chunks.append(text)
|
|
43
|
+
break
|
|
44
|
+
cut = text.rfind("\n", 0, limit)
|
|
45
|
+
if cut <= 0:
|
|
46
|
+
cut = limit
|
|
47
|
+
chunks.append(text[:cut])
|
|
48
|
+
text = text[cut:].lstrip("\n")
|
|
49
|
+
return chunks
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DiscordSlashBot(discord.Client):
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
token: str,
|
|
57
|
+
commands: list[SlashCommandSpec],
|
|
58
|
+
config: DiscordBotConfig,
|
|
59
|
+
guild_id: int | None = None,
|
|
60
|
+
owner_user_id: int | None = None,
|
|
61
|
+
logger: Callable[[str], None] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
intents = config.intents
|
|
64
|
+
if intents is None:
|
|
65
|
+
intents = discord.Intents.none()
|
|
66
|
+
intents.guilds = True
|
|
67
|
+
super().__init__(intents=intents)
|
|
68
|
+
self.tree = app_commands.CommandTree(self)
|
|
69
|
+
self._token = token
|
|
70
|
+
self._config = config
|
|
71
|
+
self._guild_id = guild_id
|
|
72
|
+
self._owner_user_id = owner_user_id
|
|
73
|
+
self._commands = commands
|
|
74
|
+
self._logger = logger or (lambda _msg: None)
|
|
75
|
+
self._register_commands()
|
|
76
|
+
|
|
77
|
+
def _register_commands(self) -> None:
|
|
78
|
+
for spec in self._commands:
|
|
79
|
+
self._add_command(spec)
|
|
80
|
+
|
|
81
|
+
def _add_command(self, spec: SlashCommandSpec) -> None:
|
|
82
|
+
@self.tree.command(name=spec.name, description=spec.description)
|
|
83
|
+
async def _command(interaction: discord.Interaction) -> None:
|
|
84
|
+
await self._run_command(interaction, spec.handler)
|
|
85
|
+
|
|
86
|
+
async def setup_hook(self) -> None:
|
|
87
|
+
if self._guild_id is not None:
|
|
88
|
+
guild = discord.Object(id=self._guild_id)
|
|
89
|
+
self.tree.copy_global_to(guild=guild)
|
|
90
|
+
synced = await self.tree.sync(guild=guild)
|
|
91
|
+
self._logger(f"Discord commands synced to guild {self._guild_id}: {len(synced)}")
|
|
92
|
+
return
|
|
93
|
+
synced = await self.tree.sync()
|
|
94
|
+
self._logger(f"Discord global commands synced: {len(synced)}")
|
|
95
|
+
|
|
96
|
+
async def on_ready(self) -> None:
|
|
97
|
+
self._logger(f"Discord bot connected as {self.user}")
|
|
98
|
+
|
|
99
|
+
async def on_guild_join(self, guild: discord.Guild) -> None:
|
|
100
|
+
if self._guild_id is not None and guild.id != self._guild_id and self._config.leave_unexpected_guild:
|
|
101
|
+
self._logger(f"Joined unexpected guild '{guild.name}' ({guild.id}), leaving.")
|
|
102
|
+
await guild.leave()
|
|
103
|
+
|
|
104
|
+
async def _run_command(self, interaction: discord.Interaction, handler: ResponseHandler) -> None:
|
|
105
|
+
if self._owner_user_id is not None and interaction.user.id != self._owner_user_id:
|
|
106
|
+
await interaction.response.send_message("Unauthorized.", ephemeral=True)
|
|
107
|
+
return
|
|
108
|
+
if interaction.guild_id is None and not self._config.allow_dm_commands:
|
|
109
|
+
await interaction.response.send_message("Use this command in the bot server.", ephemeral=True)
|
|
110
|
+
return
|
|
111
|
+
if self._guild_id is not None and interaction.guild_id != self._guild_id:
|
|
112
|
+
await interaction.response.send_message("This bot is not available in this server.", ephemeral=True)
|
|
113
|
+
return
|
|
114
|
+
await interaction.response.defer(thinking=self._config.defer_thinking)
|
|
115
|
+
try:
|
|
116
|
+
response = await asyncio.to_thread(handler)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
self._logger(f"Discord command failed: {exc}")
|
|
119
|
+
await interaction.followup.send(f"Command failed: {exc}")
|
|
120
|
+
return
|
|
121
|
+
await self._send_response(interaction, response)
|
|
122
|
+
|
|
123
|
+
async def _send_response(self, interaction: discord.Interaction, response: str | dict) -> None:
|
|
124
|
+
text = "Done"
|
|
125
|
+
image_paths: list[str] = []
|
|
126
|
+
if isinstance(response, dict):
|
|
127
|
+
text = str(response.get("text") or response.get("message") or "Done")
|
|
128
|
+
raw_images = response.get("image_paths") or []
|
|
129
|
+
if isinstance(raw_images, list):
|
|
130
|
+
image_paths = [str(path) for path in raw_images if path]
|
|
131
|
+
else:
|
|
132
|
+
text = str(response)
|
|
133
|
+
|
|
134
|
+
chunks = _split_response_text(text or "Done", limit=max(1, int(self._config.response_chunk_limit)))
|
|
135
|
+
for chunk in chunks:
|
|
136
|
+
await interaction.followup.send(chunk)
|
|
137
|
+
|
|
138
|
+
valid_files = [
|
|
139
|
+
discord.File(Path(p), filename=Path(p).name)
|
|
140
|
+
for p in image_paths[: max(0, int(self._config.max_images_per_response))]
|
|
141
|
+
if Path(p).exists()
|
|
142
|
+
]
|
|
143
|
+
if valid_files:
|
|
144
|
+
await interaction.followup.send(files=valid_files)
|
|
145
|
+
|
|
146
|
+
def run_bot(self) -> None:
|
|
147
|
+
self.run(self._token, log_handler=None)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def start_discord_bot(
|
|
151
|
+
*,
|
|
152
|
+
token: str,
|
|
153
|
+
commands: list[SlashCommandSpec] | None = None,
|
|
154
|
+
config: DiscordBotConfig | None = None,
|
|
155
|
+
guild_id: int | None = None,
|
|
156
|
+
owner_user_id: int | None = None,
|
|
157
|
+
instance_key: str = "default",
|
|
158
|
+
logger: Callable[[str], None] | None = None,
|
|
159
|
+
) -> threading.Thread:
|
|
160
|
+
resolved_commands = commands or []
|
|
161
|
+
resolved_config = config or DiscordBotConfig()
|
|
162
|
+
key = (instance_key or "default").strip() or "default"
|
|
163
|
+
|
|
164
|
+
def _run() -> None:
|
|
165
|
+
bot = DiscordSlashBot(
|
|
166
|
+
token=token,
|
|
167
|
+
commands=resolved_commands,
|
|
168
|
+
config=resolved_config,
|
|
169
|
+
guild_id=guild_id,
|
|
170
|
+
owner_user_id=owner_user_id,
|
|
171
|
+
logger=logger,
|
|
172
|
+
)
|
|
173
|
+
bot.run_bot()
|
|
174
|
+
|
|
175
|
+
with _BOT_LOCK:
|
|
176
|
+
existing = _BOT_THREADS.get(key)
|
|
177
|
+
if existing is not None and existing.is_alive():
|
|
178
|
+
return existing
|
|
179
|
+
thread_name = f"{resolved_config.thread_name_prefix}:{key}"
|
|
180
|
+
thread = threading.Thread(target=_run, name=thread_name, daemon=True)
|
|
181
|
+
thread.start()
|
|
182
|
+
_BOT_THREADS[key] = thread
|
|
183
|
+
return thread
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from .discord import send_discord_message
|
|
7
|
+
|
|
8
|
+
LoggerFn = Callable[[str], None]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def send_to_discord_env_channel(
|
|
12
|
+
channel_env_var: str,
|
|
13
|
+
content: str,
|
|
14
|
+
*,
|
|
15
|
+
image_paths: list[str] | None = None,
|
|
16
|
+
logger: LoggerFn | None = None,
|
|
17
|
+
strict_env: bool = False,
|
|
18
|
+
) -> bool:
|
|
19
|
+
"""Send a message to a Discord webhook URL stored in an environment variable."""
|
|
20
|
+
webhook_url = os.getenv(channel_env_var, "").strip()
|
|
21
|
+
if strict_env and not webhook_url:
|
|
22
|
+
raise ValueError(f"Environment variable '{channel_env_var}' is missing or empty")
|
|
23
|
+
return send_discord_message(webhook_url, content, image_paths=image_paths, logger=logger)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def parse_telegram_command(text: str) -> tuple[str, list[str]]:
|
|
5
|
+
"""Parse /command args from Telegram message text.
|
|
6
|
+
|
|
7
|
+
Supports bot mentions such as /status@my_bot.
|
|
8
|
+
"""
|
|
9
|
+
raw = (text or "").strip()
|
|
10
|
+
if not raw.startswith("/"):
|
|
11
|
+
return "", []
|
|
12
|
+
parts = raw.split()
|
|
13
|
+
if not parts:
|
|
14
|
+
return "", []
|
|
15
|
+
cmd_token = parts[0][1:].strip().lower()
|
|
16
|
+
cmd = cmd_token.split("@", 1)[0]
|
|
17
|
+
args = parts[1:]
|
|
18
|
+
return cmd, args
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Helpers to format text for Telegram plain-text messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_plain_text(text: str) -> str:
|
|
9
|
+
"""Convert markdown-like content into plain text for Telegram readability."""
|
|
10
|
+
if not text:
|
|
11
|
+
return ""
|
|
12
|
+
|
|
13
|
+
out = text
|
|
14
|
+
out = out.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\t", "\t")
|
|
15
|
+
|
|
16
|
+
out = re.sub(r"```(?:\\w+)?\n", "", out)
|
|
17
|
+
out = out.replace("```", "")
|
|
18
|
+
out = re.sub(r"^#{1,6}\\s*", "", out, flags=re.MULTILINE)
|
|
19
|
+
out = re.sub(r"\\*\\*(.*?)\\*\\*", r"\\1", out, flags=re.DOTALL)
|
|
20
|
+
out = re.sub(r"__(.*?)__", r"\\1", out, flags=re.DOTALL)
|
|
21
|
+
out = re.sub(r"`([^`]+)`", r"\\1", out)
|
|
22
|
+
out = re.sub(r"\\[([^\\]]+)\\]\\(([^)]+)\\)", r"\\1 (\\2)", out)
|
|
23
|
+
out = re.sub(r"^>\\s?", "", out, flags=re.MULTILINE)
|
|
24
|
+
out = re.sub(r"^[ \\t]*[-*+]\\s+", "- ", out, flags=re.MULTILINE)
|
|
25
|
+
out = re.sub(r"\n{3,}", "\n\n", out)
|
|
26
|
+
return out.strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def section(title: str, body: str) -> str:
|
|
30
|
+
"""Build a simple titled section for Telegram plain text."""
|
|
31
|
+
clean_body = to_plain_text(body)
|
|
32
|
+
if not clean_body:
|
|
33
|
+
return title
|
|
34
|
+
return f"{title}:\n{clean_body}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compact_lines(lines: list[str]) -> str:
|
|
38
|
+
cleaned = [line for line in lines if line is not None and str(line).strip() != ""]
|
|
39
|
+
return "\n".join(cleaned).strip()
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Telegram bot poller with background thread management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, Union
|
|
12
|
+
|
|
13
|
+
from .telegram import fetch_telegram_updates, send_telegram_message, chat_id_matches
|
|
14
|
+
from .telegram_commands import parse_telegram_command
|
|
15
|
+
|
|
16
|
+
TelegramCommandHandler = Callable[[list[str]], Union[str, dict]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TelegramCommandSpec:
|
|
21
|
+
"""Specification for a Telegram bot command."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
"""Command name (e.g., "status", "trade")."""
|
|
25
|
+
|
|
26
|
+
description: str
|
|
27
|
+
"""Human-readable description."""
|
|
28
|
+
|
|
29
|
+
handler: TelegramCommandHandler
|
|
30
|
+
"""Function that handles the command. Receives list of args, returns response."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TelegramPollerConfig:
|
|
35
|
+
"""Configuration for Telegram polling behavior."""
|
|
36
|
+
|
|
37
|
+
poll_timeout_seconds: int = 30
|
|
38
|
+
"""Timeout for long polling (0-based)."""
|
|
39
|
+
|
|
40
|
+
max_retries: int = 3
|
|
41
|
+
"""Max retries on network error before giving up for that cycle."""
|
|
42
|
+
|
|
43
|
+
retry_delay_seconds: int = 5
|
|
44
|
+
"""Delay between retries."""
|
|
45
|
+
|
|
46
|
+
allowed_updates: list[str] | None = None
|
|
47
|
+
"""Filter updates (e.g., ["message", "callback_query"]). None = all."""
|
|
48
|
+
|
|
49
|
+
offset_file: str | None = None
|
|
50
|
+
"""Path to file for persisting offset (auto-created if missing)."""
|
|
51
|
+
|
|
52
|
+
thread_name_prefix: str = "telegram-poller"
|
|
53
|
+
"""Prefix for thread naming."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_TELEGRAM_POLLERS: dict[str, threading.Thread] = {}
|
|
57
|
+
"""Map of instance_key -> polling thread."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_offset(offset_file: str | None) -> int:
|
|
61
|
+
"""Load saved offset from file, or 0 if missing."""
|
|
62
|
+
if not offset_file:
|
|
63
|
+
return 0
|
|
64
|
+
path = Path(offset_file)
|
|
65
|
+
if path.exists():
|
|
66
|
+
try:
|
|
67
|
+
return int(path.read_text().strip())
|
|
68
|
+
except (ValueError, OSError):
|
|
69
|
+
return 0
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _save_offset(offset_file: str | None, offset: int) -> None:
|
|
74
|
+
"""Save offset to file."""
|
|
75
|
+
if not offset_file:
|
|
76
|
+
return
|
|
77
|
+
path = Path(offset_file)
|
|
78
|
+
try:
|
|
79
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
path.write_text(str(offset))
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_telegram_poller(
|
|
86
|
+
token: str,
|
|
87
|
+
commands: list[TelegramCommandSpec],
|
|
88
|
+
config: TelegramPollerConfig,
|
|
89
|
+
logger: Callable[[str], None] | None = None,
|
|
90
|
+
stop_event: threading.Event | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Background polling loop for Telegram updates."""
|
|
93
|
+
|
|
94
|
+
def log(msg: str) -> None:
|
|
95
|
+
if logger:
|
|
96
|
+
logger(f"[telegram-poller] {msg}")
|
|
97
|
+
|
|
98
|
+
offset = _load_offset(config.offset_file)
|
|
99
|
+
log(f"Starting polling with offset={offset}")
|
|
100
|
+
|
|
101
|
+
command_map: dict[str, TelegramCommandHandler] = {cmd.name: cmd.handler for cmd in commands}
|
|
102
|
+
|
|
103
|
+
while not (stop_event and stop_event.is_set()):
|
|
104
|
+
try:
|
|
105
|
+
updates = fetch_telegram_updates(
|
|
106
|
+
token=token,
|
|
107
|
+
timeout=config.poll_timeout_seconds,
|
|
108
|
+
allowed_updates=config.allowed_updates,
|
|
109
|
+
offset=offset,
|
|
110
|
+
retries=config.max_retries,
|
|
111
|
+
retry_delay=config.retry_delay_seconds,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
for update in updates:
|
|
115
|
+
try:
|
|
116
|
+
message = update.get("message", {})
|
|
117
|
+
if not message:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
text = message.get("text", "").strip()
|
|
121
|
+
chat_id = message.get("chat", {}).get("id")
|
|
122
|
+
|
|
123
|
+
if not text or not chat_id:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Parse command
|
|
127
|
+
cmd, args = parse_telegram_command(text)
|
|
128
|
+
if not cmd:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Dispatch to handler
|
|
132
|
+
handler = command_map.get(cmd)
|
|
133
|
+
if not handler:
|
|
134
|
+
log(f"Unknown command: /{cmd} from chat {chat_id}")
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
log(f"Handling /{cmd} with args {args} from chat {chat_id}")
|
|
138
|
+
response = handler(args)
|
|
139
|
+
|
|
140
|
+
# Send response
|
|
141
|
+
if isinstance(response, dict):
|
|
142
|
+
response_text = json.dumps(response)
|
|
143
|
+
else:
|
|
144
|
+
response_text = str(response)
|
|
145
|
+
|
|
146
|
+
send_telegram_message(token, chat_id, response_text)
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
log(f"Error handling update {update.get('update_id')}: {e}")
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
# Update offset after successful processing
|
|
153
|
+
offset = update.get("update_id", offset) + 1
|
|
154
|
+
_save_offset(config.offset_file, offset)
|
|
155
|
+
|
|
156
|
+
except Exception as e:
|
|
157
|
+
log(f"Polling error: {e}")
|
|
158
|
+
time.sleep(config.retry_delay_seconds)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def start_telegram_poller(
|
|
162
|
+
*,
|
|
163
|
+
token: str,
|
|
164
|
+
commands: list[TelegramCommandSpec],
|
|
165
|
+
config: TelegramPollerConfig | None = None,
|
|
166
|
+
logger: Callable[[str], None] | None = None,
|
|
167
|
+
instance_key: str = "default",
|
|
168
|
+
) -> threading.Thread:
|
|
169
|
+
"""
|
|
170
|
+
Start a background Telegram polling thread.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
token: Telegram bot token.
|
|
174
|
+
commands: List of TelegramCommandSpec for command handlers.
|
|
175
|
+
config: TelegramPollerConfig for polling behavior. Defaults to TelegramPollerConfig().
|
|
176
|
+
logger: Optional logger function.
|
|
177
|
+
instance_key: Unique key for this poller instance (allows multiple pollers per process).
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
The polling thread (already started).
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ValueError: If instance_key is already in use (to prevent duplicate pollers).
|
|
184
|
+
|
|
185
|
+
Example:
|
|
186
|
+
>>> config = TelegramPollerConfig(poll_timeout_seconds=30, offset_file="/tmp/tg_offset")
|
|
187
|
+
>>> commands = [
|
|
188
|
+
... TelegramCommandSpec("status", "Get status", handler=on_status),
|
|
189
|
+
... TelegramCommandSpec("trade", "Execute trade", handler=on_trade),
|
|
190
|
+
... ]
|
|
191
|
+
>>> thread = start_telegram_poller(token=token, commands=commands, config=config)
|
|
192
|
+
>>> # Thread is now running in background
|
|
193
|
+
"""
|
|
194
|
+
if config is None:
|
|
195
|
+
config = TelegramPollerConfig()
|
|
196
|
+
|
|
197
|
+
if instance_key in _TELEGRAM_POLLERS:
|
|
198
|
+
raise ValueError(f"Telegram poller with instance_key='{instance_key}' already running")
|
|
199
|
+
|
|
200
|
+
stop_event = threading.Event()
|
|
201
|
+
thread = threading.Thread(
|
|
202
|
+
name=f"{config.thread_name_prefix}-{instance_key}",
|
|
203
|
+
target=_run_telegram_poller,
|
|
204
|
+
args=(token, commands, config, logger, stop_event),
|
|
205
|
+
daemon=True,
|
|
206
|
+
)
|
|
207
|
+
thread.start()
|
|
208
|
+
_TELEGRAM_POLLERS[instance_key] = thread
|
|
209
|
+
|
|
210
|
+
return thread
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chatops-bridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Reusable Telegram and Discord ChatOps utilities for Python projects
|
|
5
|
+
Author: ChatOps Bridge Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/trunghvbk-afataw/chatops
|
|
8
|
+
Project-URL: Repository, https://github.com/trunghvbk-afataw/chatops
|
|
9
|
+
Project-URL: Issues, https://github.com/trunghvbk-afataw/chatops/issues
|
|
10
|
+
Keywords: telegram,discord,webhook,chatops,notifications
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Communications :: Chat
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: awesome-notify-bridge>=0.1.5
|
|
26
|
+
Provides-Extra: discord-bot
|
|
27
|
+
Requires-Dist: discord.py>=2.4.0; extra == "discord-bot"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# chatops-bridge
|
|
31
|
+
|
|
32
|
+
Reusable Telegram and Discord utilities for Python applications.
|
|
33
|
+
|
|
34
|
+
This package was extracted from production automation code and generalized for multi-purpose ChatOps use.
|
|
35
|
+
Transport-level Telegram/Discord sending is reused from `awesome-notify-bridge`.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- Telegram send/poll helpers:
|
|
40
|
+
- send text and images
|
|
41
|
+
- split long text by Telegram limit
|
|
42
|
+
- poll updates and extract message text
|
|
43
|
+
- robust chat id matching
|
|
44
|
+
- Discord webhook sender:
|
|
45
|
+
- send message chunks safely
|
|
46
|
+
- upload image files
|
|
47
|
+
- Discord slash bot:
|
|
48
|
+
- register any slash commands dynamically
|
|
49
|
+
- optional owner and guild restriction
|
|
50
|
+
- Plain text formatter helpers for chat-friendly output
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
Requires Python 3.9+.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install chatops-bridge
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For slash-bot support:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install "chatops-bridge[discord-bot]"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Install from GitHub:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install git+https://github.com/<your-org>/chatops-bridge.git
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Quick Start
|
|
73
|
+
|
|
74
|
+
### Telegram
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from chatops_bridge.telegram import send_telegram_message
|
|
78
|
+
|
|
79
|
+
send_telegram_message(
|
|
80
|
+
chat_id="123456789",
|
|
81
|
+
token="<telegram-bot-token>",
|
|
82
|
+
text="Hello from chatops-bridge",
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Send with images:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
send_telegram_message(
|
|
90
|
+
chat_id="123456789",
|
|
91
|
+
token="<telegram-bot-token>",
|
|
92
|
+
text="Report attached",
|
|
93
|
+
image_paths=["./charts/btc.png", "./charts/eth.png"],
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Discord webhook
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from chatops_bridge.discord import send_discord_message
|
|
101
|
+
|
|
102
|
+
send_discord_message(
|
|
103
|
+
webhook_url="https://discord.com/api/webhooks/...",
|
|
104
|
+
content="Hello from chatops-bridge",
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Return value is `True` on success and `False` on failure.
|
|
109
|
+
|
|
110
|
+
### Discord env-based channel
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from chatops_bridge.discord_channels import send_to_discord_env_channel
|
|
114
|
+
|
|
115
|
+
# export DISCORD_ALERT_WEBHOOK_URL=...
|
|
116
|
+
send_to_discord_env_channel("DISCORD_ALERT_WEBHOOK_URL", "Alert triggered")
|
|
117
|
+
|
|
118
|
+
# strict mode: fail fast if env var is missing
|
|
119
|
+
send_to_discord_env_channel("DISCORD_ALERT_WEBHOOK_URL", "Alert triggered", strict_env=True)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Useful when you want channel routing from environment variables instead of hardcoding webhook URLs.
|
|
123
|
+
|
|
124
|
+
## Telegram Update Polling
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from chatops_bridge.telegram import fetch_telegram_updates, extract_update_text
|
|
128
|
+
|
|
129
|
+
updates = fetch_telegram_updates(
|
|
130
|
+
token="<telegram-bot-token>",
|
|
131
|
+
offset=None,
|
|
132
|
+
timeout_seconds=10,
|
|
133
|
+
allowed_updates=["message", "edited_message"],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
for update in updates:
|
|
137
|
+
text, message = extract_update_text(update)
|
|
138
|
+
if text:
|
|
139
|
+
print("message:", text)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Discord Slash Bot
|
|
143
|
+
|
|
144
|
+
`chatops_bridge.discord_bot.start_discord_bot(...)` starts a background thread for a Discord bot using your command list.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
import discord
|
|
148
|
+
|
|
149
|
+
from chatops_bridge.discord_bot import DiscordBotConfig, SlashCommandSpec, start_discord_bot
|
|
150
|
+
|
|
151
|
+
def ping() -> str:
|
|
152
|
+
return "pong"
|
|
153
|
+
|
|
154
|
+
def report() -> dict:
|
|
155
|
+
return {"text": "Report done", "image_paths": ["./out/chart.png"]}
|
|
156
|
+
|
|
157
|
+
start_discord_bot(
|
|
158
|
+
token="<discord-bot-token>",
|
|
159
|
+
commands=[
|
|
160
|
+
SlashCommandSpec(name="ping", description="Health check", handler=ping),
|
|
161
|
+
SlashCommandSpec(name="report", description="Generate report", handler=report),
|
|
162
|
+
],
|
|
163
|
+
config=DiscordBotConfig(
|
|
164
|
+
intents=discord.Intents(guilds=True),
|
|
165
|
+
allow_dm_commands=False,
|
|
166
|
+
leave_unexpected_guild=True,
|
|
167
|
+
response_chunk_limit=1900,
|
|
168
|
+
max_images_per_response=10,
|
|
169
|
+
defer_thinking=True,
|
|
170
|
+
),
|
|
171
|
+
guild_id=None,
|
|
172
|
+
owner_user_id=None,
|
|
173
|
+
instance_key="primary",
|
|
174
|
+
)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
You can also run the bot with `commands=[]` (or omit `commands`) when your app only needs Discord client events/background tasks.
|
|
178
|
+
|
|
179
|
+
`instance_key` is used to manage bot thread instances by key. Calling `start_discord_bot` again with the same key returns the existing live thread.
|
|
180
|
+
|
|
181
|
+
## Slash Bot Example
|
|
182
|
+
|
|
183
|
+
See [examples/discord_bot_example.py](examples/discord_bot_example.py).
|
|
184
|
+
|
|
185
|
+
## Telegram Poller Bot
|
|
186
|
+
|
|
187
|
+
`chatops_bridge.telegram_poller.start_telegram_poller(...)` starts a background polling thread that automatically handles Telegram updates and dispatches them to your command handlers.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from chatops_bridge.telegram_poller import TelegramCommandSpec, TelegramPollerConfig, start_telegram_poller
|
|
191
|
+
|
|
192
|
+
def handle_status(args: list[str]) -> str:
|
|
193
|
+
return "Status: OK"
|
|
194
|
+
|
|
195
|
+
def handle_trade(args: list[str]) -> str:
|
|
196
|
+
amount = float(args[0]) if args else 100.0
|
|
197
|
+
return f"Trading {amount} units"
|
|
198
|
+
|
|
199
|
+
start_telegram_poller(
|
|
200
|
+
token="<telegram-bot-token>",
|
|
201
|
+
commands=[
|
|
202
|
+
TelegramCommandSpec(
|
|
203
|
+
name="status",
|
|
204
|
+
description="Get bot status",
|
|
205
|
+
handler=handle_status,
|
|
206
|
+
),
|
|
207
|
+
TelegramCommandSpec(
|
|
208
|
+
name="trade",
|
|
209
|
+
description="Execute trade",
|
|
210
|
+
handler=handle_trade,
|
|
211
|
+
),
|
|
212
|
+
],
|
|
213
|
+
config=TelegramPollerConfig(
|
|
214
|
+
poll_timeout_seconds=30,
|
|
215
|
+
max_retries=3,
|
|
216
|
+
retry_delay_seconds=5,
|
|
217
|
+
allowed_updates=["message", "edited_message"],
|
|
218
|
+
offset_file="/tmp/telegram_offset.txt", # Persist offset on restart
|
|
219
|
+
),
|
|
220
|
+
logger=print, # Optional logger
|
|
221
|
+
)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The poller:
|
|
225
|
+
- Runs in background thread(s)
|
|
226
|
+
- Automatically saves offset to persist progress across restarts
|
|
227
|
+
- Retries on network errors with configurable backoff
|
|
228
|
+
- Filters update types (e.g., messages only, skip reactions)
|
|
229
|
+
- Parses `/command arg1 arg2` style messages
|
|
230
|
+
- Dispatches to appropriate handler
|
|
231
|
+
- Sends response back to the originating chat
|
|
232
|
+
|
|
233
|
+
Handlers receive a list of arguments and return `str` or `dict` (JSON-serialized if dict).
|
|
234
|
+
|
|
235
|
+
## Telegram Poller Example
|
|
236
|
+
|
|
237
|
+
See [examples/telegram_poller_example.py](examples/telegram_poller_example.py).
|
|
238
|
+
|
|
239
|
+
## Common Environment Variables
|
|
240
|
+
|
|
241
|
+
- `DISCORD_BOT_TOKEN`: bot token used by the slash-bot example.
|
|
242
|
+
- `DISCORD_BOT_GUILD_ID`: optional guild restriction.
|
|
243
|
+
- `DISCORD_BOT_OWNER_USER_ID`: optional owner-only restriction.
|
|
244
|
+
- `TELEGRAM_BOT_TOKEN`: polling bot token.
|
|
245
|
+
- `TELEGRAM_BOT_OFFSET_FILE`: optional path to persist polling offset (for restart safety).
|
|
246
|
+
- Custom webhook URL variables (for env-based routing), for example:
|
|
247
|
+
- `DISCORD_ALERT_WEBHOOK_URL`
|
|
248
|
+
- `DISCORD_SIGNAL_WEBHOOK_URL`
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
MIT
|
|
253
|
+
|
|
254
|
+
## Contributing
|
|
255
|
+
|
|
256
|
+
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
|
257
|
+
- Development setup
|
|
258
|
+
- Making changes and submitting PRs
|
|
259
|
+
- Release procedures
|
|
260
|
+
|
|
261
|
+
For questions, open an issue on [GitHub](https://github.com/trunghvbk-afataw/chatops/issues).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/chatops_bridge/__init__.py
|
|
5
|
+
src/chatops_bridge/discord.py
|
|
6
|
+
src/chatops_bridge/discord_bot.py
|
|
7
|
+
src/chatops_bridge/discord_channels.py
|
|
8
|
+
src/chatops_bridge/telegram.py
|
|
9
|
+
src/chatops_bridge/telegram_commands.py
|
|
10
|
+
src/chatops_bridge/telegram_format.py
|
|
11
|
+
src/chatops_bridge/telegram_poller.py
|
|
12
|
+
src/chatops_bridge.egg-info/PKG-INFO
|
|
13
|
+
src/chatops_bridge.egg-info/SOURCES.txt
|
|
14
|
+
src/chatops_bridge.egg-info/dependency_links.txt
|
|
15
|
+
src/chatops_bridge.egg-info/requires.txt
|
|
16
|
+
src/chatops_bridge.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chatops_bridge
|