main
py 243 lines 7.84 KB
Raw
1 import asyncio
2 from dataclasses import dataclass
3 from typing import Callable, Awaitable
4
5 from aiogram import Bot, Dispatcher, Router, F
6 from aiogram.client.default import DefaultBotProperties
7 from aiogram.enums import ParseMode, ChatType, ContentType
8 from aiogram.filters import Command, CommandStart
9 from aiogram.types import BotCommand, Message
10 from helpers import integration_commands
11
12 from helpers.errors import format_error
13 from helpers.print_style import PrintStyle
14
15 # Data models
16
17 @dataclass
18 class BotInstance:
19 name: str
20 bot: Bot
21 dispatcher: Dispatcher
22 router: Router
23 task: asyncio.Task | None = None # polling task
24 webhook_active: bool = False # True when webhook mode is registered
25 webhook_secret: str = "" # secret for webhook verification
26 group_mode: str = "mention" # current group_mode setting
27 bot_info: object | None = None # cached result of bot.get_me()
28
29 # Bot registry (singleton, persists across module reloads)
30
31 _bots: dict[str, BotInstance] = {}
32
33
34 def get_bot(name: str) -> BotInstance | None:
35 return _bots.get(name)
36
37
38 def get_all_bots() -> dict[str, BotInstance]:
39 return _bots
40
41 # Bot creation
42
43 def create_bot(
44 name: str,
45 token: str,
46 on_message: Callable[..., Awaitable],
47 on_command_start: Callable[..., Awaitable],
48 on_command_control: Callable[..., Awaitable] | None = None,
49 on_callback_query: Callable[..., Awaitable] | None = None,
50 on_new_members: Callable[..., Awaitable] | None = None,
51 group_mode: str = "mention",
52 ) -> BotInstance:
53 bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
54 dp = Dispatcher()
55 router = Router()
56
57 # Register command handlers
58 router.message.register(on_command_start, CommandStart())
59 if on_command_control:
60 router.message.register(
61 on_command_control,
62 Command(commands=integration_commands.command_names(integration="telegram")),
63 )
64
65 if on_callback_query:
66 router.callback_query.register(on_callback_query)
67
68 if on_new_members:
69 router.message.register(on_new_members, F.content_type == ContentType.NEW_CHAT_MEMBERS)
70
71 # Register message handler with group filtering
72 if group_mode == "off":
73 # Private chats only
74 router.message.register(
75 on_message, F.chat.type == ChatType.PRIVATE,
76 )
77 elif group_mode == "mention":
78 # Private chats: all messages; Groups: only when mentioned/replied
79 router.message.register(
80 on_message, F.chat.type == ChatType.PRIVATE,
81 )
82 router.message.register(
83 _make_group_mention_filter(on_message, bot),
84 )
85 else:
86 # All messages in all chats
87 router.message.register(on_message)
88
89 dp.include_router(router)
90 instance = BotInstance(name=name, bot=bot, dispatcher=dp, router=router, group_mode=group_mode)
91 _bots[name] = instance
92 return instance
93
94
95 async def register_bot_commands(instance: BotInstance) -> None:
96 """Register Telegram's native / command menu from the shared integration registry."""
97 commands = [
98 BotCommand(command=name, description=description)
99 for name, description in integration_commands.telegram_menu_commands()
100 ]
101 if not commands:
102 return
103 try:
104 await instance.bot.set_my_commands(commands)
105 PrintStyle.info(f"Telegram ({instance.name}): registered {len(commands)} bot commands")
106 except Exception as e:
107 PrintStyle.error(f"Telegram ({instance.name}): failed to register bot commands: {format_error(e)}")
108
109
110 async def cache_bot_info(instance: BotInstance):
111 """Fetch and cache bot info. Call after create_bot."""
112 if not instance.bot_info:
113 instance.bot_info = await instance.bot.get_me()
114 return instance.bot_info
115
116
117 def _make_group_mention_filter(handler: Callable, bot: Bot):
118 """Create a group message handler that only responds to mentions and replies."""
119 async def _group_handler(message: Message):
120 if message.chat.type == ChatType.PRIVATE:
121 return
122 # Use cached bot_info from the instance
123 bot_info = None
124 for b in _bots.values():
125 if b.bot is bot:
126 bot_info = b.bot_info
127 break
128 if not bot_info:
129 bot_info = await bot.get_me()
130 bot_username = bot_info.username or ""
131
132 # Check for reply to bot
133 if message.reply_to_message and message.reply_to_message.from_user:
134 if message.reply_to_message.from_user.id == bot_info.id:
135 await handler(message)
136 return
137
138 # Check for @mention in text or caption (media messages use caption)
139 text = message.text or message.caption or ""
140 entities = message.entities or message.caption_entities or []
141
142 if text and f"@{bot_username}" in text:
143 await handler(message)
144 return
145
146 # Check entities for mention
147 for entity in entities:
148 if entity.type == "mention":
149 mention_text = text[entity.offset:entity.offset + entity.length]
150 if mention_text.lower() == f"@{bot_username.lower()}":
151 await handler(message)
152 return
153
154 _group_handler.__name__ = f"_group_handler_{id(handler)}"
155 return _group_handler
156
157 # Polling
158
159 async def start_polling(instance: BotInstance) -> asyncio.Task:
160 # Ensure any leftover webhook is removed before polling
161 try:
162 await instance.bot.delete_webhook()
163 except Exception:
164 pass
165
166 async def _poll():
167 try:
168 PrintStyle.info(f"Telegram ({instance.name}): starting polling")
169 await instance.dispatcher.start_polling(
170 instance.bot,
171 handle_signals=False,
172 )
173 except asyncio.CancelledError:
174 PrintStyle.info(f"Telegram ({instance.name}): polling cancelled")
175 except Exception as e:
176 PrintStyle.error(f"Telegram ({instance.name}): polling error: {format_error(e)}")
177
178 task = asyncio.create_task(_poll())
179 instance.task = task
180 return task
181
182
183 async def stop_polling(instance: BotInstance):
184 if instance.task and not instance.task.done():
185 await instance.dispatcher.stop_polling()
186 instance.task.cancel()
187 try:
188 await instance.task
189 except asyncio.CancelledError:
190 pass
191 instance.task = None
192
193 # Webhook
194
195 async def setup_webhook(instance: BotInstance, webhook_url: str, secret: str = ""):
196 """Register webhook with Telegram. Updates are received via the API handler."""
197 full_url = f"{webhook_url.rstrip('/')}/api/plugins/_telegram_integration/webhook?bot={instance.name}"
198
199 await instance.bot.set_webhook(
200 url=full_url,
201 secret_token=secret or None,
202 )
203
204 instance.webhook_active = True
205 instance.webhook_secret = secret
206 PrintStyle.info(f"Telegram ({instance.name}): webhook active via {webhook_url.rstrip('/')}")
207
208
209 async def remove_webhook(instance: BotInstance):
210 try:
211 await instance.bot.delete_webhook()
212 except Exception as e:
213 PrintStyle.error(f"Telegram ({instance.name}): remove webhook error: {format_error(e)}")
214 instance.webhook_active = False
215 instance.webhook_secret = ""
216
217 # Cleanup
218
219 async def stop_bot(name: str):
220 instance = _bots.pop(name, None)
221 if not instance:
222 return
223 if instance.task and not instance.task.done():
224 await stop_polling(instance)
225 else:
226 await remove_webhook(instance)
227 try:
228 await instance.bot.session.close()
229 except Exception:
230 pass
231 PrintStyle.info(f"Telegram ({name}): stopped")
232
233
234 # Test connection
235
236 async def test_token(token: str) -> tuple[bool, str]:
237 try:
238 bot = Bot(token=token)
239 info = await bot.get_me()
240 await bot.session.close()
241 return True, f"Connected as @{info.username} ({info.first_name})"
242 except Exception as e:
243 return False, format_error(e)