tunnelmate-cli 1.0.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.
@@ -0,0 +1,767 @@
1
+ """Telegram Bot management interface for TunnelMate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import sys
9
+ from typing import Optional
10
+ import telegram.error
11
+ from telegram.request import HTTPXRequest
12
+ from telegram import (
13
+ InlineKeyboardButton,
14
+ InlineKeyboardMarkup,
15
+ Update,
16
+ )
17
+ from telegram.constants import ParseMode
18
+ from telegram.ext import (
19
+ Application,
20
+ ApplicationBuilder,
21
+ CallbackQueryHandler,
22
+ CommandHandler,
23
+ ContextTypes,
24
+ MessageHandler,
25
+ filters,
26
+ )
27
+
28
+ from tunnelmate.client.ipc_client import TunnelMateClient
29
+ from tunnelmate.config import Settings, load_settings
30
+ from tunnelmate.core.models import HealthStatus, Tunnel, TunnelProtocol, TunnelStatus
31
+ from tunnelmate.daemon.protocol import IPCRequest, IPCResponse
32
+ from tunnelmate.db.repository import Repository
33
+ from tunnelmate.exceptions import DaemonConnectionError, TunnelMateError
34
+
35
+ logger = logging.getLogger("tunnelmate.bot")
36
+
37
+
38
+ class TunnelMateBot:
39
+ """Telegram Bot Controller providing menu-based remote tunnel management."""
40
+
41
+ def __init__(self, settings: Optional[Settings] = None):
42
+ self.settings = settings or load_settings()
43
+ self.repo = Repository(self.settings.resolved_db_path)
44
+ self.client = TunnelMateClient(self.settings)
45
+ self.app: Optional[Application] = None
46
+ self._background_tasks: list[asyncio.Task] = []
47
+
48
+ def is_admin(self, user_id: int) -> bool:
49
+ """Check if user has administrative rights."""
50
+ if user_id in self.settings.admin_chat_ids:
51
+ return True
52
+ sub = self.repo.get_subscriber(user_id)
53
+ return bool(sub and sub.is_admin and sub.is_authenticated)
54
+
55
+ # --- UI Keyboards ---
56
+
57
+ def _main_menu_keyboard(self, user_id: int) -> InlineKeyboardMarkup:
58
+ is_adm = self.is_admin(user_id)
59
+ keyboard = [
60
+ [
61
+ InlineKeyboardButton("📋 Tunnels List", callback_data="menu:list"),
62
+ InlineKeyboardButton("🔄 Refresh", callback_data="menu:refresh"),
63
+ ]
64
+ ]
65
+ if is_adm:
66
+ keyboard.append([
67
+ InlineKeyboardButton("➕ Create Tunnel", callback_data="menu:create_hint"),
68
+ InlineKeyboardButton("📊 Daemon Stats", callback_data="menu:stats"),
69
+ ])
70
+ keyboard.append([
71
+ InlineKeyboardButton("🔔 Notifications", callback_data="menu:notify_settings"),
72
+ InlineKeyboardButton("❓ Help", callback_data="menu:help"),
73
+ ])
74
+ return InlineKeyboardMarkup(keyboard)
75
+
76
+ def _tunnels_list_keyboard(self, tunnels: list[Tunnel]) -> InlineKeyboardMarkup:
77
+ keyboard = []
78
+ for t in tunnels:
79
+ icon = "🟢" if t.status == TunnelStatus.RUNNING else "🔴"
80
+ btn_text = f"{icon} {t.name} (:{t.target_port})"
81
+ keyboard.append([InlineKeyboardButton(btn_text, callback_data=f"tun:{t.id}")])
82
+
83
+ keyboard.append([
84
+ InlineKeyboardButton("🔄 Refresh", callback_data="menu:list"),
85
+ InlineKeyboardButton("🔙 Back to Main Menu", callback_data="menu:main"),
86
+ ])
87
+ return InlineKeyboardMarkup(keyboard)
88
+
89
+ def _tunnel_action_keyboard(self, tunnel: Tunnel, is_adm: bool) -> InlineKeyboardMarkup:
90
+ keyboard = []
91
+
92
+ if is_adm:
93
+ if tunnel.status == TunnelStatus.RUNNING:
94
+ keyboard.append([
95
+ InlineKeyboardButton("⏹️ Stop", callback_data=f"act:stop:{tunnel.id}"),
96
+ InlineKeyboardButton("🔄 Restart", callback_data=f"act:restart:{tunnel.id}"),
97
+ ])
98
+ else:
99
+ keyboard.append([
100
+ InlineKeyboardButton("▶️ Start", callback_data=f"act:start:{tunnel.id}"),
101
+ ])
102
+
103
+ row_link = [InlineKeyboardButton("🔗 Latest Link", callback_data=f"act:link:{tunnel.id}")]
104
+ if tunnel.current_url and tunnel.status == TunnelStatus.RUNNING:
105
+ row_link.append(InlineKeyboardButton("🌐 Open", url=tunnel.current_url))
106
+ keyboard.append(row_link)
107
+
108
+ if is_adm:
109
+ keyboard.append([
110
+ InlineKeyboardButton("🩺 Health Check", callback_data=f"act:health:{tunnel.id}"),
111
+ InlineKeyboardButton("✏️ Change Port", callback_data=f"act:port_hint:{tunnel.id}"),
112
+ ])
113
+ keyboard.append([
114
+ InlineKeyboardButton("🗑️ Delete", callback_data=f"act:delete:{tunnel.id}"),
115
+ ])
116
+
117
+ keyboard.append([
118
+ InlineKeyboardButton("🔙 Back to Tunnels", callback_data="menu:list"),
119
+ ])
120
+ return InlineKeyboardMarkup(keyboard)
121
+
122
+ # --- Command Handlers ---
123
+
124
+ async def cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
125
+ """Handle /start command and render initial dashboard."""
126
+ if not update.effective_user or not update.effective_message:
127
+ return
128
+
129
+ user = update.effective_user
130
+ sub = self.repo.get_or_create_subscriber(
131
+ chat_id=user.id,
132
+ username=user.username,
133
+ first_name=user.first_name,
134
+ is_admin=(user.id in self.settings.admin_chat_ids),
135
+ )
136
+
137
+ is_adm = self.is_admin(user.id)
138
+ admin_badge = "👑 <b>Administrator</b>" if is_adm else "👤 <b>Subscriber</b>"
139
+
140
+ text = (
141
+ f"⚡ <b>TunnelMate Control Hub</b>\n\n"
142
+ f"Welcome, {user.first_name}!\n"
143
+ f"Access Level: {admin_badge}\n\n"
144
+ )
145
+
146
+ if not is_adm and self.settings.has_admin_password():
147
+ text += "🔒 <i>To unlock admin controls, authenticate using:</i>\n<code>/login &lt;password&gt;</code>\n\n"
148
+
149
+ try:
150
+ tunnels = await asyncio.to_thread(self.client.list_tunnels)
151
+ running = sum(1 for t in tunnels if t.status == TunnelStatus.RUNNING)
152
+ text += f"📊 <b>Active Tunnels:</b> {running} / {len(tunnels)}\n"
153
+ except Exception:
154
+ text += "⚠️ <i>(Daemon is offline or starting...)</i>\n"
155
+
156
+ text += "Select an option below to manage tunnels:"
157
+
158
+ await update.effective_message.reply_text(
159
+ text,
160
+ reply_markup=self._main_menu_keyboard(user.id),
161
+ parse_mode=ParseMode.HTML,
162
+ )
163
+
164
+ async def cmd_login(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
165
+ """Handle /login <password> authentication."""
166
+ if not update.effective_user or not update.effective_message:
167
+ return
168
+
169
+ # Attempt to delete password message for privacy
170
+ try:
171
+ await update.effective_message.delete()
172
+ except Exception:
173
+ pass
174
+
175
+ if not context.args:
176
+ await update.effective_chat.send_message("❌ Usage: <code>/login &lt;password&gt;</code>", parse_mode=ParseMode.HTML)
177
+ return
178
+
179
+ entered = " ".join(context.args).strip()
180
+ if self.settings.verify_admin_password(entered):
181
+ sub = self.repo.get_or_create_subscriber(update.effective_user.id)
182
+ sub.is_admin = True
183
+ sub.is_authenticated = True
184
+ self.repo.update_subscriber(sub)
185
+
186
+ await update.effective_chat.send_message(
187
+ "✅ <b>Authentication successful!</b>\nYou now have full administrator access.",
188
+ reply_markup=self._main_menu_keyboard(update.effective_user.id),
189
+ parse_mode=ParseMode.HTML,
190
+ )
191
+ else:
192
+ await update.effective_chat.send_message("❌ <b>Invalid password.</b> Access denied.", parse_mode=ParseMode.HTML)
193
+
194
+ async def cmd_logout(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
195
+ """Handle /logout to clear admin session."""
196
+ if not update.effective_user:
197
+ return
198
+ sub = self.repo.get_subscriber(update.effective_user.id)
199
+ if sub and update.effective_user.id not in self.settings.admin_chat_ids:
200
+ sub.is_authenticated = False
201
+ self.repo.update_subscriber(sub)
202
+ await update.effective_chat.send_message("👋 Logged out of administrator session.")
203
+
204
+ async def cmd_create(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
205
+ """Handle /create <name> <port> [protocol]."""
206
+ if not update.effective_user or not self.is_admin(update.effective_user.id):
207
+ await update.effective_chat.send_message("⛔ Administrator access required.")
208
+ return
209
+
210
+ if not context.args or len(context.args) < 2:
211
+ await update.effective_chat.send_message(
212
+ "❌ Usage: <code>/create &lt;name&gt; &lt;port&gt; [protocol]</code>\n"
213
+ "Example: <code>/create my-app 8080 http</code>",
214
+ parse_mode=ParseMode.HTML,
215
+ )
216
+ return
217
+
218
+ name = context.args[0]
219
+ try:
220
+ port = int(context.args[1])
221
+ except ValueError:
222
+ await update.effective_chat.send_message("❌ Port must be an integer between 1 and 65535.")
223
+ return
224
+
225
+ proto = context.args[2].lower() if len(context.args) > 2 else "http"
226
+
227
+ try:
228
+ tun = await asyncio.to_thread(
229
+ self.client.create_tunnel,
230
+ name=name,
231
+ port=port,
232
+ protocol=proto,
233
+ )
234
+ await update.effective_chat.send_message(
235
+ f"✅ Tunnel <b>{tun.name}</b> created forwarding to <code>{tun.target_url}</code>!\n"
236
+ f"You can now start it from the menu.",
237
+ reply_markup=self._tunnel_action_keyboard(tun, True),
238
+ parse_mode=ParseMode.HTML,
239
+ )
240
+ except Exception as e:
241
+ await update.effective_chat.send_message(f"❌ Error creating tunnel: {e}")
242
+
243
+ async def cmd_port(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
244
+ """Handle /port <name> <new_port>."""
245
+ if not update.effective_user or not self.is_admin(update.effective_user.id):
246
+ await update.effective_chat.send_message("⛔ Administrator access required.")
247
+ return
248
+
249
+ if not context.args or len(context.args) < 2:
250
+ await update.effective_chat.send_message("❌ Usage: <code>/port &lt;tunnel_name&gt; &lt;new_port&gt;</code>", parse_mode=ParseMode.HTML)
251
+ return
252
+
253
+ name = context.args[0]
254
+ try:
255
+ new_port = int(context.args[1])
256
+ except ValueError:
257
+ await update.effective_chat.send_message("❌ Invalid port number.")
258
+ return
259
+
260
+ try:
261
+ tun = await asyncio.to_thread(self.client.change_port, name, new_port)
262
+ url_text = f"\n🔗 <b>New URL:</b> {tun.current_url}" if tun.current_url else ""
263
+ await update.effective_chat.send_message(
264
+ f"✅ Port for tunnel <b>{tun.name}</b> updated to <b>{new_port}</b>!{url_text}",
265
+ reply_markup=self._tunnel_action_keyboard(tun, True),
266
+ parse_mode=ParseMode.HTML,
267
+ )
268
+ except Exception as e:
269
+ await update.effective_chat.send_message(f"❌ Failed to update port: {e}")
270
+
271
+ # --- Callback Query Handlers ---
272
+
273
+ async def handle_callback_query(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
274
+ """Process inline button clicks."""
275
+ query = update.callback_query
276
+ if not query or not query.data or not update.effective_user:
277
+ return
278
+
279
+ try:
280
+ await query.answer()
281
+ except Exception:
282
+ pass
283
+
284
+ data = query.data
285
+ user_id = update.effective_user.id
286
+ is_adm = self.is_admin(user_id)
287
+
288
+ try:
289
+ if data == "menu:main":
290
+ await query.edit_message_text(
291
+ "⚡ <b>TunnelMate Control Hub</b>\nSelect an option below:",
292
+ reply_markup=self._main_menu_keyboard(user_id),
293
+ parse_mode=ParseMode.HTML,
294
+ )
295
+
296
+ elif data == "menu:refresh":
297
+ tunnels = await asyncio.to_thread(self.client.list_tunnels)
298
+ running = sum(1 for t in tunnels if t.status == TunnelStatus.RUNNING)
299
+ await query.edit_message_text(
300
+ f"⚡ <b>TunnelMate Control Hub</b> (Refreshed)\n"
301
+ f"📊 <b>Active Tunnels:</b> {running} / {len(tunnels)}\n\n"
302
+ f"Select an option below:",
303
+ reply_markup=self._main_menu_keyboard(user_id),
304
+ parse_mode=ParseMode.HTML,
305
+ )
306
+
307
+ elif data == "menu:list":
308
+ tunnels = await asyncio.to_thread(self.client.list_tunnels)
309
+ if not tunnels:
310
+ await query.edit_message_text(
311
+ "📋 <b>Tunnels List</b>\n\nNo tunnels created yet. Create one with /create or via CLI.",
312
+ reply_markup=self._main_menu_keyboard(user_id),
313
+ parse_mode=ParseMode.HTML,
314
+ )
315
+ else:
316
+ await query.edit_message_text(
317
+ f"📋 <b>Registered Tunnels ({len(tunnels)})</b>\nClick a tunnel to view controls:",
318
+ reply_markup=self._tunnels_list_keyboard(tunnels),
319
+ parse_mode=ParseMode.HTML,
320
+ )
321
+
322
+ elif data == "menu:create_hint":
323
+ await query.edit_message_text(
324
+ "➕ <b>Create New Quick Tunnel</b>\n\n"
325
+ "Send command in chat:\n"
326
+ "<code>/create &lt;name&gt; &lt;port&gt; [protocol]</code>\n\n"
327
+ "Example:\n<code>/create dev-api 8080 http</code>",
328
+ reply_markup=InlineKeyboardMarkup([[
329
+ InlineKeyboardButton("🔙 Back", callback_data="menu:main")
330
+ ]]),
331
+ parse_mode=ParseMode.HTML,
332
+ )
333
+
334
+ elif data == "menu:stats":
335
+ stats = await asyncio.to_thread(self.client.get_stats)
336
+ msg = (
337
+ "📊 <b>Daemon Performance Stats</b>\n\n"
338
+ f"• <b>PID:</b> {stats.get('pid')}\n"
339
+ f"• <b>Uptime:</b> {stats.get('uptime_seconds')}s\n"
340
+ f"• <b>Total Tunnels:</b> {stats.get('total_tunnels')}\n"
341
+ f"• <b>Active Tunnels:</b> {stats.get('running_tunnels')}\n"
342
+ )
343
+ await query.edit_message_text(
344
+ msg,
345
+ reply_markup=InlineKeyboardMarkup([[
346
+ InlineKeyboardButton("🔙 Back", callback_data="menu:main")
347
+ ]]),
348
+ parse_mode=ParseMode.HTML,
349
+ )
350
+
351
+ elif data == "menu:notify_settings":
352
+ sub = self.repo.get_or_create_subscriber(user_id)
353
+ new_state = not sub.notify_on_url_change
354
+ sub.notify_on_url_change = new_state
355
+ sub.notify_on_status_change = new_state
356
+ self.repo.update_subscriber(sub)
357
+
358
+ state_str = "🔔 <b>ENABLED</b>" if new_state else "🔕 <b>DISABLED</b>"
359
+ await query.edit_message_text(
360
+ f"Notification preferences updated:\n"
361
+ f"Alerts are now: {state_str}\n\n"
362
+ f"You will receive instant alerts when tunnels start, change links, or recover.",
363
+ reply_markup=InlineKeyboardMarkup([[
364
+ InlineKeyboardButton("Toggle Again", callback_data="menu:notify_settings"),
365
+ InlineKeyboardButton("🔙 Back", callback_data="menu:main"),
366
+ ]]),
367
+ parse_mode=ParseMode.HTML,
368
+ )
369
+
370
+ elif data == "menu:help":
371
+ help_text = (
372
+ "❓ <b>TunnelMate Help & Commands</b>\n\n"
373
+ "• <code>/start</code> - Open main interactive menu\n"
374
+ "• <code>/login &lt;password&gt;</code> - Authenticate as Admin\n"
375
+ "• <code>/create &lt;name&gt; &lt;port&gt;</code> - Create new tunnel\n"
376
+ "• <code>/port &lt;name&gt; &lt;new_port&gt;</code> - Change target port\n"
377
+ "• <code>/logout</code> - Exit admin session\n"
378
+ )
379
+ await query.edit_message_text(
380
+ help_text,
381
+ reply_markup=InlineKeyboardMarkup([[
382
+ InlineKeyboardButton("🔙 Back", callback_data="menu:main")
383
+ ]]),
384
+ parse_mode=ParseMode.HTML,
385
+ )
386
+
387
+ elif data.startswith("tun:"):
388
+ tunnel_id = data.split(":", 1)[1]
389
+ tunnel = await asyncio.to_thread(self.client.get_tunnel, tunnel_id)
390
+ if not tunnel:
391
+ await query.edit_message_text("❌ Tunnel not found.", reply_markup=self._main_menu_keyboard(user_id))
392
+ return
393
+
394
+ msg = (
395
+ f"⚡ <b>Tunnel:</b> <code>{tunnel.name}</code>\n"
396
+ f"• <b>Status:</b> {'🟢 RUNNING' if tunnel.status == TunnelStatus.RUNNING else '🔴 STOPPED'}\n"
397
+ f"• <b>Target:</b> <code>{tunnel.target_url}</code>\n"
398
+ f"• <b>Auto-Restart:</b> {'Enabled' if tunnel.auto_restart else 'Disabled'}\n"
399
+ f"• <b>PID:</b> {tunnel.pid or 'None'}\n\n"
400
+ )
401
+ if tunnel.current_url and tunnel.status == TunnelStatus.RUNNING:
402
+ msg += f"🔗 <b>Public URL:</b>\n{tunnel.current_url}\n"
403
+ else:
404
+ msg += "🔗 <i>Public URL not currently active.</i>\n"
405
+
406
+ await query.edit_message_text(
407
+ msg,
408
+ reply_markup=self._tunnel_action_keyboard(tunnel, is_adm),
409
+ parse_mode=ParseMode.HTML,
410
+ )
411
+
412
+ elif data.startswith("act:"):
413
+ _, action, tunnel_id = data.split(":", 2)
414
+ tunnel = await asyncio.to_thread(self.client.get_tunnel, tunnel_id)
415
+ if not tunnel:
416
+ await query.answer("Tunnel not found!", show_alert=True)
417
+ return
418
+
419
+ if action in ("start", "stop", "restart", "delete") and not is_adm:
420
+ await query.answer("⛔ Admin access required!", show_alert=True)
421
+ return
422
+
423
+ if action == "link":
424
+ if tunnel.current_url and tunnel.status == TunnelStatus.RUNNING:
425
+ text = (
426
+ f"🌐 <b>Quick Tunnel URL for {tunnel.name}:</b>\n\n"
427
+ f"<code>{tunnel.current_url}</code>\n\n"
428
+ f"🎯 Forwarding to: <code>{tunnel.target_url}</code>"
429
+ )
430
+ if tunnel.protocol in (TunnelProtocol.TCP, TunnelProtocol.TLS) or tunnel.target_port == 22:
431
+ host = tunnel.current_url.replace("https://", "").replace("http://", "").strip("/")
432
+ text += (
433
+ f"\n\n💡 <b>Direct SSH Command:</b>\n"
434
+ f"<code>tunnelmate-ssh user@{host}</code>\n"
435
+ f"<i>(Or: <code>tunnelmate ssh user@{host}</code>)</i>\n"
436
+ )
437
+ btn = InlineKeyboardMarkup([
438
+ [InlineKeyboardButton("🌐 Open in Browser", url=tunnel.current_url)],
439
+ [InlineKeyboardButton("🔙 Back to Tunnel", callback_data=f"tun:{tunnel.id}")],
440
+ ])
441
+ await query.edit_message_text(text, reply_markup=btn, parse_mode=ParseMode.HTML)
442
+ else:
443
+ await query.answer("Tunnel is not currently running!", show_alert=True)
444
+
445
+ elif action == "start":
446
+ await query.edit_message_text(f"⏳ Starting tunnel <b>{tunnel.name}</b>...", parse_mode=ParseMode.HTML)
447
+ started = await asyncio.to_thread(self.client.start_tunnel, tunnel.id)
448
+ await query.edit_message_text(
449
+ f"✅ Tunnel <b>{started.name}</b> is now RUNNING!\n\n"
450
+ f"🔗 <b>URL:</b> {started.current_url or 'Acquired'}",
451
+ reply_markup=self._tunnel_action_keyboard(started, is_adm),
452
+ parse_mode=ParseMode.HTML,
453
+ )
454
+
455
+ elif action == "stop":
456
+ await query.edit_message_text(f"⏳ Stopping tunnel <b>{tunnel.name}</b>...", parse_mode=ParseMode.HTML)
457
+ stopped = await asyncio.to_thread(self.client.stop_tunnel, tunnel.id)
458
+ await query.edit_message_text(
459
+ f"🛑 Tunnel <b>{stopped.name}</b> has been stopped.",
460
+ reply_markup=self._tunnel_action_keyboard(stopped, is_adm),
461
+ parse_mode=ParseMode.HTML,
462
+ )
463
+
464
+ elif action == "restart":
465
+ await query.edit_message_text(f"🔄 Restarting tunnel <b>{tunnel.name}</b>...", parse_mode=ParseMode.HTML)
466
+ restarted = await asyncio.to_thread(self.client.restart_tunnel, tunnel.id)
467
+ await query.edit_message_text(
468
+ f"✅ Tunnel <b>{restarted.name}</b> restarted!\n\n"
469
+ f"🔗 <b>New URL:</b> {restarted.current_url}",
470
+ reply_markup=self._tunnel_action_keyboard(restarted, is_adm),
471
+ parse_mode=ParseMode.HTML,
472
+ )
473
+
474
+ elif action == "health":
475
+ health, details = await asyncio.to_thread(self.client.check_health, tunnel.id)
476
+ h_icon = "🟢" if health == HealthStatus.HEALTHY else "🔴"
477
+ msg = (
478
+ f"🩺 <b>Health Diagnostic: {tunnel.name}</b>\n\n"
479
+ f"• <b>Status:</b> {h_icon} {health.value.upper()}\n"
480
+ f"• <b>Diagnosis:</b> {details.get('message')}\n"
481
+ f"• <b>Local Port Open:</b> {'Yes' if details.get('local_port_open') else 'No'}\n"
482
+ )
483
+ await query.edit_message_text(
484
+ msg,
485
+ reply_markup=self._tunnel_action_keyboard(tunnel, is_adm),
486
+ parse_mode=ParseMode.HTML,
487
+ )
488
+
489
+ elif action == "port_hint":
490
+ await query.edit_message_text(
491
+ f"✏️ <b>Change Port for {tunnel.name}</b>\n\n"
492
+ f"Current Port: <code>{tunnel.target_port}</code>\n\n"
493
+ f"Send command in chat:\n"
494
+ f"<code>/port {tunnel.name} &lt;new_port&gt;</code>\n"
495
+ f"Example: <code>/port {tunnel.name} 9000</code>",
496
+ reply_markup=InlineKeyboardMarkup([[
497
+ InlineKeyboardButton("🔙 Back", callback_data=f"tun:{tunnel.id}")
498
+ ]]),
499
+ parse_mode=ParseMode.HTML,
500
+ )
501
+
502
+ elif action == "delete":
503
+ deleted = await asyncio.to_thread(self.client.delete_tunnel, tunnel.id)
504
+ if deleted:
505
+ await query.edit_message_text(
506
+ f"🗑️ Tunnel <b>{tunnel.name}</b> deleted successfully.",
507
+ reply_markup=self._main_menu_keyboard(user_id),
508
+ parse_mode=ParseMode.HTML,
509
+ )
510
+
511
+ except telegram.error.BadRequest as e:
512
+ if "Message is not modified" in str(e):
513
+ pass
514
+ else:
515
+ logger.warning("Telegram BadRequest: %s", e)
516
+ except (telegram.error.NetworkError, telegram.error.TimedOut) as e:
517
+ logger.warning("Transient network error during callback query: %s", e)
518
+ except Exception as e:
519
+ logger.error("Error handling callback: %s", e, exc_info=True)
520
+ try:
521
+ await query.answer(f"Error: {e}", show_alert=True)
522
+ except Exception:
523
+ pass
524
+
525
+ # --- Live Subscriber Event Broadcast Loop ---
526
+
527
+ async def _event_listener_loop(self) -> None:
528
+ """Connect to daemon IPC socket, subscribe to events, and broadcast to Telegram users."""
529
+ use_unix = (sys.platform != "win32") and (not self.settings.use_tcp_ipc)
530
+
531
+ while True:
532
+ try:
533
+ if use_unix:
534
+ reader, writer = await asyncio.open_unix_connection(
535
+ path=str(self.settings.resolved_socket_path)
536
+ )
537
+ else:
538
+ reader, writer = await asyncio.open_connection(
539
+ host=self.settings.tcp_ipc_host,
540
+ port=self.settings.tcp_ipc_port,
541
+ )
542
+
543
+ # Send subscribe request
544
+ req = IPCRequest(id="bot-sub", action="subscribe_events")
545
+ writer.write(req.to_line())
546
+ await writer.drain()
547
+
548
+ # Read ack
549
+ await reader.readline()
550
+
551
+ logger.info("Telegram Bot successfully subscribed to daemon events.")
552
+
553
+ while True:
554
+ line_bytes = await reader.readline()
555
+ if not line_bytes:
556
+ break
557
+
558
+ line = line_bytes.decode("utf-8").strip()
559
+ if not line:
560
+ continue
561
+
562
+ try:
563
+ import json
564
+ raw = json.loads(line)
565
+ if raw.get("type") == "event":
566
+ await self._broadcast_event(raw.get("event"), raw.get("tunnel_id"), raw.get("data", {}))
567
+ except Exception as e:
568
+ logger.error("Error processing event line: %s", e)
569
+
570
+ except (asyncio.CancelledError, KeyboardInterrupt):
571
+ break
572
+ except Exception as e:
573
+ logger.debug("Daemon IPC not yet available for event listener: %s. Retrying in 5s...", e)
574
+ await asyncio.sleep(5.0)
575
+
576
+ async def _broadcast_event(self, event_type: str, tunnel_id: str, data: dict) -> None:
577
+ """Send notification to registered subscribers."""
578
+ if not self.app:
579
+ return
580
+
581
+ name = data.get("tunnel_name", tunnel_id)
582
+ url = data.get("url")
583
+
584
+ msg = ""
585
+ btn = None
586
+
587
+ if event_type == "url_assigned" and url:
588
+ msg = (
589
+ f"⚡ <b>Quick Tunnel Online!</b>\n\n"
590
+ f"Tunnel: <b>{name}</b>\n"
591
+ f"🔗 <b>New URL:</b>\n{url}\n"
592
+ )
593
+ btn = InlineKeyboardMarkup([[InlineKeyboardButton("🌐 Open in Browser", url=url)]])
594
+
595
+ elif event_type == "crashed":
596
+ msg = f"⚠️ <b>Tunnel Alert:</b> '{name}' crashed! Attempting automatic recovery..."
597
+
598
+ elif event_type == "recovered" and url:
599
+ msg = (
600
+ f"✅ <b>Tunnel Recovered!</b>\n\n"
601
+ f"Tunnel: <b>{name}</b>\n"
602
+ f"🔗 <b>Restored URL:</b>\n{url}\n"
603
+ )
604
+ btn = InlineKeyboardMarkup([[InlineKeyboardButton("🌐 Open in Browser", url=url)]])
605
+
606
+ elif event_type == "stopped":
607
+ msg = f"🛑 <b>Tunnel Stopped:</b> '{name}' was stopped."
608
+
609
+ if not msg:
610
+ return
611
+
612
+ # Fetch active subscribers from SQLite
613
+ subscribers = await asyncio.to_thread(self.repo.list_subscribers, only_active=True)
614
+ for sub in subscribers:
615
+ try:
616
+ await self.app.bot.send_message(
617
+ chat_id=sub.chat_id,
618
+ text=msg,
619
+ reply_markup=btn,
620
+ parse_mode=ParseMode.HTML,
621
+ )
622
+ except Exception as e:
623
+ logger.debug("Failed sending broadcast to chat_id %d: %s", sub.chat_id, e)
624
+
625
+ async def error_handler(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
626
+ """Handle errors caused by Updates."""
627
+ err = context.error
628
+ if isinstance(err, (telegram.error.NetworkError, telegram.error.TimedOut)):
629
+ logger.warning("Transient network issue with Telegram API: %s", err)
630
+ return
631
+ if isinstance(err, telegram.error.BadRequest) and "Message is not modified" in str(err):
632
+ return
633
+ logger.error("Unhandled exception in Telegram bot: %s", err, exc_info=err)
634
+
635
+ def _write_pid(self) -> None:
636
+ """Write current PID to bot.pid."""
637
+ pid_file = self.settings.bot_pid_path
638
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
639
+ with open(pid_file, "w", encoding="utf-8") as f:
640
+ f.write(str(os.getpid()))
641
+
642
+ def _remove_pid(self) -> None:
643
+ """Clean up bot.pid on termination."""
644
+ pid_file = self.settings.bot_pid_path
645
+ if pid_file.exists():
646
+ try:
647
+ pid_file.unlink()
648
+ except OSError:
649
+ pass
650
+
651
+ @classmethod
652
+ def get_running_pid(cls, settings: Optional[Settings] = None) -> Optional[int]:
653
+ """Check if bot process is currently running by PID."""
654
+ st = settings or load_settings()
655
+ pid_file = st.bot_pid_path
656
+ if not pid_file.exists():
657
+ return None
658
+ try:
659
+ pid = int(pid_file.read_text().strip())
660
+ os.kill(pid, 0)
661
+ return pid
662
+ except (ValueError, OSError, ProcessLookupError):
663
+ return None
664
+
665
+ @classmethod
666
+ def fetch_recent_chats(cls, token: Optional[str], exclude_chat_ids: Optional[set[int]] = None) -> list[dict]:
667
+ """Fetch unrecorded recent users from Telegram getUpdates API."""
668
+ if not token:
669
+ return []
670
+ import httpx
671
+ exclude = exclude_chat_ids or set()
672
+ recent = []
673
+ seen = set(exclude)
674
+ try:
675
+ with httpx.Client(timeout=5.0) as http_client:
676
+ resp = http_client.get(f"https://api.telegram.org/bot{token}/getUpdates")
677
+ if resp.status_code == 200:
678
+ data = resp.json()
679
+ for upd in data.get("result", []):
680
+ msg = upd.get("message") or upd.get("callback_query", {}).get("message") or {}
681
+ chat = msg.get("chat") or upd.get("callback_query", {}).get("from") or {}
682
+ c_id = chat.get("id")
683
+ if c_id and c_id not in seen:
684
+ seen.add(c_id)
685
+ first_name = chat.get("first_name", "")
686
+ username = chat.get("username")
687
+ recent.append({
688
+ "chat_id": c_id,
689
+ "name": first_name or "Unknown",
690
+ "username": f"@{username}" if username else "—",
691
+ "notify": "—",
692
+ "created": "Telegram Updates",
693
+ })
694
+ except Exception:
695
+ pass
696
+ return recent
697
+
698
+ async def start(self) -> None:
699
+ """Build and run Telegram bot application."""
700
+ token = self.settings.telegram_bot_token
701
+ if not token:
702
+ logger.error("Telegram bot token not configured! Set via 'tunnelmate bot set-token <token>'.")
703
+ return
704
+
705
+ self._write_pid()
706
+
707
+ request_config = HTTPXRequest(
708
+ connection_pool_size=8,
709
+ read_timeout=30.0,
710
+ write_timeout=20.0,
711
+ connect_timeout=20.0,
712
+ pool_timeout=15.0,
713
+ )
714
+ self.app = (
715
+ ApplicationBuilder()
716
+ .token(token)
717
+ .request(request_config)
718
+ .build()
719
+ )
720
+ self.app.add_error_handler(self.error_handler)
721
+
722
+ # Register handlers
723
+ self.app.add_handler(CommandHandler("start", self.cmd_start))
724
+ self.app.add_handler(CommandHandler("login", self.cmd_login))
725
+ self.app.add_handler(CommandHandler("logout", self.cmd_logout))
726
+ self.app.add_handler(CommandHandler("create", self.cmd_create))
727
+ self.app.add_handler(CommandHandler("port", self.cmd_port))
728
+ self.app.add_handler(CallbackQueryHandler(self.handle_callback_query))
729
+
730
+ # Launch event listener background task
731
+ task = asyncio.create_task(self._event_listener_loop())
732
+ self._background_tasks.append(task)
733
+
734
+ logger.info("TunnelMate Telegram Bot initialized (PID: %d). Starting polling...", os.getpid())
735
+ await self.app.initialize()
736
+ await self.app.start()
737
+ await self.app.updater.start_polling()
738
+
739
+ try:
740
+ while True:
741
+ await asyncio.sleep(1.0)
742
+ finally:
743
+ if self.app.updater:
744
+ await self.app.updater.stop()
745
+ await self.app.stop()
746
+ await self.app.shutdown()
747
+ for t in self._background_tasks:
748
+ t.cancel()
749
+ self._remove_pid()
750
+
751
+
752
+ def run_telegram_bot() -> None:
753
+ """Entry point to launch the Telegram bot process."""
754
+ logging.basicConfig(
755
+ level=logging.INFO,
756
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
757
+ )
758
+ bot = TunnelMateBot()
759
+ try:
760
+ asyncio.run(bot.start())
761
+ except KeyboardInterrupt:
762
+ pass
763
+
764
+
765
+ if __name__ == "__main__":
766
+ run_telegram_bot()
767
+