telemux 1.0.1__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.

Potentially problematic release.


This version of telemux might be problematic. Click here for more details.

telemux/listener.py ADDED
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Telegram Listener Daemon for TeleMux
4
+ Monitors Telegram bot for incoming messages and routes them to LLM agents
5
+ """
6
+
7
+ import os
8
+ import re
9
+ import sys
10
+ import json
11
+ import time
12
+ import logging
13
+ import requests
14
+ import subprocess
15
+ import shlex
16
+ from pathlib import Path
17
+ from datetime import datetime
18
+ from typing import Dict, List, Optional, Tuple, Any
19
+
20
+ from . import TELEMUX_DIR, MESSAGE_QUEUE_DIR, LOG_FILE, TMUX_SESSION
21
+ from .config import load_config
22
+
23
+ # Message queue files
24
+ OUTGOING_LOG = MESSAGE_QUEUE_DIR / "outgoing.log"
25
+ INCOMING_LOG = MESSAGE_QUEUE_DIR / "incoming.log"
26
+ LISTENER_STATE = MESSAGE_QUEUE_DIR / "listener_state.json"
27
+
28
+ # Logging setup
29
+ ERROR_LOG_FILE = TELEMUX_DIR / "telegram_errors.log"
30
+
31
+ # Get log level from environment variable (default: INFO)
32
+ LOG_LEVEL = os.environ.get('TELEMUX_LOG_LEVEL', 'INFO').upper()
33
+ LOG_LEVEL_MAP = {
34
+ 'DEBUG': logging.DEBUG,
35
+ 'INFO': logging.INFO,
36
+ 'WARNING': logging.WARNING,
37
+ 'ERROR': logging.ERROR,
38
+ 'CRITICAL': logging.CRITICAL
39
+ }
40
+
41
+ # Configure logging with multiple handlers
42
+ logger = logging.getLogger('TelegramListener')
43
+ logger.setLevel(LOG_LEVEL_MAP.get(LOG_LEVEL, logging.INFO))
44
+
45
+ # Main log file handler (all levels)
46
+ main_handler = logging.FileHandler(LOG_FILE)
47
+ main_handler.setLevel(logging.DEBUG)
48
+ main_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
49
+ main_handler.setFormatter(main_formatter)
50
+
51
+ # Error log file handler (errors only)
52
+ error_handler = logging.FileHandler(ERROR_LOG_FILE)
53
+ error_handler.setLevel(logging.ERROR)
54
+ error_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
55
+ error_handler.setFormatter(error_formatter)
56
+
57
+ # Console handler (configurable level)
58
+ console_handler = logging.StreamHandler()
59
+ console_handler.setLevel(LOG_LEVEL_MAP.get(LOG_LEVEL, logging.INFO))
60
+ console_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
61
+ console_handler.setFormatter(console_formatter)
62
+
63
+ logger.addHandler(main_handler)
64
+ logger.addHandler(error_handler)
65
+ logger.addHandler(console_handler)
66
+
67
+
68
+ def load_state() -> Dict:
69
+ """Load listener state (last update ID)"""
70
+ if LISTENER_STATE.exists():
71
+ with open(LISTENER_STATE) as f:
72
+ return json.load(f)
73
+ return {"last_update_id": 0}
74
+
75
+
76
+ def save_state(state: Dict):
77
+ """Save listener state"""
78
+ MESSAGE_QUEUE_DIR.mkdir(parents=True, exist_ok=True)
79
+ with open(LISTENER_STATE, 'w') as f:
80
+ json.dump(state, f, indent=2)
81
+
82
+
83
+ def get_telegram_updates(bot_token: str, offset: int = 0, max_retries: int = 3) -> List[Dict]:
84
+ """Poll Telegram for new messages with retry logic"""
85
+ url = f"https://api.telegram.org/bot{bot_token}/getUpdates"
86
+ params = {
87
+ "offset": offset,
88
+ "timeout": 30 # Long polling
89
+ }
90
+
91
+ for attempt in range(max_retries):
92
+ try:
93
+ response = requests.get(url, params=params, timeout=35)
94
+ response.raise_for_status()
95
+ data = response.json()
96
+
97
+ if data.get("ok"):
98
+ return data.get("result", [])
99
+ else:
100
+ logger.warning(f"Telegram API returned not ok: {data}")
101
+ return []
102
+
103
+ except requests.exceptions.Timeout:
104
+ # Timeout is expected with long polling, only log if it's a problem
105
+ if attempt < max_retries - 1:
106
+ logger.debug(f"Telegram long-poll timeout (attempt {attempt + 1}/{max_retries})")
107
+ time.sleep(2 ** attempt)
108
+ else:
109
+ logger.warning(f"Failed to get updates after {max_retries} timeout attempts")
110
+ return []
111
+
112
+ except requests.exceptions.ConnectionError as e:
113
+ logger.warning(f"Connection error (attempt {attempt + 1}/{max_retries}): {e}")
114
+ if attempt < max_retries - 1:
115
+ time.sleep(2 ** attempt) # Exponential backoff
116
+ else:
117
+ logger.error(f"Failed to connect after {max_retries} attempts. Is the network down?")
118
+ return []
119
+
120
+ except requests.exceptions.RequestException as e:
121
+ logger.warning(f"Request error (attempt {attempt + 1}/{max_retries}): {e}")
122
+ if attempt < max_retries - 1:
123
+ time.sleep(2 ** attempt)
124
+ else:
125
+ logger.error(f"Failed to get updates after {max_retries} attempts: {e}")
126
+ return []
127
+
128
+ except Exception as e:
129
+ logger.error(f"Unexpected error getting Telegram updates: {e}")
130
+ return []
131
+
132
+ return []
133
+
134
+
135
+ def send_telegram_message(bot_token: str, chat_id: str, text: str, max_retries: int = 3):
136
+ """Send a message to Telegram with retry logic"""
137
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
138
+ payload = {
139
+ "chat_id": chat_id,
140
+ "text": text,
141
+ "parse_mode": "HTML"
142
+ }
143
+
144
+ for attempt in range(max_retries):
145
+ try:
146
+ response = requests.post(url, json=payload, timeout=10)
147
+ response.raise_for_status()
148
+ logger.info(f"Sent message to Telegram: {text[:50]}...")
149
+ return True
150
+
151
+ except requests.exceptions.Timeout:
152
+ logger.warning(f"Telegram API timeout (attempt {attempt + 1}/{max_retries})")
153
+ if attempt < max_retries - 1:
154
+ time.sleep(2 ** attempt) # Exponential backoff: 1s, 2s, 4s
155
+ else:
156
+ logger.error(f"Failed to send message after {max_retries} attempts (timeout)")
157
+ return False
158
+
159
+ except requests.exceptions.RequestException as e:
160
+ logger.warning(f"Telegram API error (attempt {attempt + 1}/{max_retries}): {e}")
161
+ if attempt < max_retries - 1:
162
+ time.sleep(2 ** attempt) # Exponential backoff
163
+ else:
164
+ logger.error(f"Failed to send message after {max_retries} attempts: {e}")
165
+ return False
166
+
167
+ except Exception as e:
168
+ logger.error(f"Unexpected error sending Telegram message: {e}")
169
+ return False
170
+
171
+ return False
172
+
173
+
174
+ def parse_message_id(text: str) -> Optional[Tuple[str, str]]:
175
+ """
176
+ Parse message ID and response from text
177
+ Expected formats:
178
+ - session-name: Your response here (new format)
179
+ - msg-1234567890-12345: Your response here (old format)
180
+ Returns: (message_id, response) or None
181
+ """
182
+ # Match either session name (letters, numbers, dashes, underscores) or old msg format
183
+ pattern = r'^([\w-]+):\s*(.+)$'
184
+ match = re.match(pattern, text, re.DOTALL)
185
+ if match:
186
+ return match.group(1), match.group(2)
187
+ return None
188
+
189
+
190
+ def process_update(update: Dict[str, Any], bot_token: str, chat_id: str) -> None:
191
+ """Process a single Telegram update - SESSION-BASED ROUTING
192
+
193
+ Args:
194
+ update: Telegram update dict containing message data
195
+ bot_token: Telegram bot token
196
+ chat_id: Telegram chat ID
197
+
198
+ Returns:
199
+ None
200
+ """
201
+ if "message" not in update:
202
+ return
203
+
204
+ message = update["message"]
205
+ text = message.get("text", "")
206
+ from_user = message.get("from", {}).get("first_name", "Unknown")
207
+
208
+ logger.info(f"Received message from {from_user}: {text[:50]}...")
209
+
210
+ # Parse message for session: message format
211
+ parsed = parse_message_id(text)
212
+ if not parsed:
213
+ logger.info("Message doesn't match format (session-name: message), ignoring")
214
+ return
215
+
216
+ session_name, response = parsed
217
+ logger.info(f"Parsed message - Target session: {session_name}")
218
+
219
+ # Check if tmux session exists
220
+ try:
221
+ result = subprocess.run(
222
+ ['tmux', 'list-sessions', '-F', '#{session_name}'],
223
+ capture_output=True,
224
+ text=True,
225
+ check=False
226
+ )
227
+
228
+ if result.returncode != 0:
229
+ # No tmux sessions at all
230
+ logger.warning("No tmux sessions found")
231
+ send_telegram_message(bot_token, chat_id, f"No tmux sessions are running")
232
+ return
233
+
234
+ active_sessions = [s for s in result.stdout.strip().split('\n') if s]
235
+
236
+ if session_name not in active_sessions:
237
+ logger.warning(f"Tmux session not found: {session_name}")
238
+ # Security: Show count only, not all session names
239
+ send_telegram_message(bot_token, chat_id, f"Session <b>{session_name}</b> not found. {len(active_sessions)} active session(s).")
240
+ return
241
+
242
+ # SECURITY: Sanitize user input to prevent command injection
243
+ # tmux send-keys interprets special characters like $(), ``, &&, ;
244
+ # Without sanitization, malicious input could execute arbitrary commands
245
+ safe_response = shlex.quote(response)
246
+ formatted_message = f"{safe_response}\n # Respond using this terminal command: tg_agent \"{session_name}\" \"your response\""
247
+
248
+ # Send message to tmux session
249
+ result = subprocess.run(
250
+ ['tmux', 'send-keys', '-t', session_name, formatted_message],
251
+ capture_output=True,
252
+ text=True,
253
+ check=False
254
+ )
255
+
256
+ if result.returncode != 0:
257
+ logger.error(f"Failed to send message to tmux: {result.stderr}")
258
+ send_telegram_message(bot_token, chat_id, f"Failed to deliver message to session")
259
+ return
260
+
261
+ # CRITICAL: Sleep required for tmux to buffer text before Enter is sent
262
+ # Without this delay, tmux doesn't have time to process send-keys and
263
+ # the message gets lost. See: https://github.com/tmux/tmux/issues/1254
264
+ time.sleep(1)
265
+
266
+ # Send Enter to execute the command
267
+ result = subprocess.run(
268
+ ['tmux', 'send-keys', '-t', session_name, 'C-m'],
269
+ capture_output=True,
270
+ text=True,
271
+ check=False
272
+ )
273
+
274
+ if result.returncode != 0:
275
+ logger.error(f"Failed to send Enter to tmux: {result.stderr}")
276
+ send_telegram_message(bot_token, chat_id, f"Message sent but not executed")
277
+ return
278
+
279
+ logger.info(f"Message delivered to tmux session: {session_name}")
280
+ logger.info(f"Content: {response}")
281
+
282
+ # Send confirmation
283
+ send_telegram_message(bot_token, chat_id, f"Message delivered to <b>{session_name}</b>")
284
+
285
+ except Exception as e:
286
+ logger.error(f"Failed to send message: {e}")
287
+ send_telegram_message(bot_token, chat_id, f"Error: {str(e)}")
288
+
289
+
290
+ def main():
291
+ """Main listener loop"""
292
+ logger.info("=" * 60)
293
+ logger.info("Telegram Listener Daemon Starting")
294
+ logger.info("=" * 60)
295
+
296
+ # Load config
297
+ bot_token, chat_id = load_config()
298
+ if not bot_token or not chat_id:
299
+ logger.error("Failed to load Telegram config. Please run: telemux-install")
300
+ sys.exit(1)
301
+
302
+ logger.info(f"Loaded Telegram config - Chat ID: {chat_id}")
303
+
304
+ # Create directories
305
+ MESSAGE_QUEUE_DIR.mkdir(parents=True, exist_ok=True)
306
+
307
+ # Load state
308
+ state = load_state()
309
+ offset = state["last_update_id"]
310
+
311
+ logger.info(f"Starting from update offset: {offset}")
312
+ logger.info("Listening for messages...")
313
+
314
+ try:
315
+ while True:
316
+ updates = get_telegram_updates(bot_token, offset)
317
+
318
+ for update in updates:
319
+ update_id = update["update_id"]
320
+
321
+ # Process update
322
+ try:
323
+ process_update(update, bot_token, chat_id)
324
+ except Exception as e:
325
+ logger.error(f"Error processing update {update_id}: {e}")
326
+
327
+ # Update offset
328
+ offset = update_id + 1
329
+ state["last_update_id"] = offset
330
+ save_state(state)
331
+
332
+ # Small sleep if no updates
333
+ if not updates:
334
+ time.sleep(1)
335
+
336
+ except KeyboardInterrupt:
337
+ logger.info("\nListener stopped by user")
338
+ sys.exit(0)
339
+ except Exception as e:
340
+ logger.error(f"Fatal error: {e}")
341
+ sys.exit(1)
342
+
343
+
344
+ if __name__ == "__main__":
345
+ main()
@@ -0,0 +1,110 @@
1
+ #!/bin/bash
2
+ #
3
+ # TeleMux Shell Functions - Single Source of Truth
4
+ #
5
+ # This file contains all shell functions for TeleMux.
6
+ # Source this file to get: tg_alert, tg_agent, tg_done
7
+ #
8
+ # Deployed to: ~/.telemux/shell_functions.sh
9
+ # Sourced by: ~/.zshrc (via INSTALL.sh)
10
+ #
11
+
12
+ # Load TeleMux configuration
13
+ if [ -f "$HOME/.telemux/telegram_config" ]; then
14
+ source "$HOME/.telemux/telegram_config"
15
+ fi
16
+
17
+ # Simple alert function - NOW BIDIRECTIONAL (can receive replies)
18
+ tg_alert() {
19
+ local message="$*"
20
+ if [[ -z "$message" ]]; then
21
+ echo "Usage: tg_alert <message>"
22
+ return 1
23
+ fi
24
+
25
+ if [[ -z "$TELEMUX_TG_BOT_TOKEN" ]] || [[ -z "$TELEMUX_TG_CHAT_ID" ]]; then
26
+ echo "Error: TeleMux not configured. Check ~/.telemux/telegram_config"
27
+ return 1
28
+ fi
29
+
30
+ # Get tmux session name for context AND routing
31
+ local tmux_session="$(tmux display-message -p '#S' 2>/dev/null || echo 'terminal')"
32
+
33
+ # NEW: Send with reply instructions (makes tg_alert bidirectional)
34
+ curl -s -X POST "https://api.telegram.org/bot${TELEMUX_TG_BOT_TOKEN}/sendMessage" \
35
+ -d chat_id="${TELEMUX_TG_CHAT_ID}" \
36
+ -d text="[!] <b>[${tmux_session}]</b> ${message}
37
+
38
+ <i>Reply: ${tmux_session}: your response</i>" \
39
+ -d parse_mode="HTML" > /dev/null && echo "Message sent to Telegram"
40
+ }
41
+
42
+ # Bidirectional agent alert - sends message and can receive replies
43
+ tg_agent() {
44
+ local agent_name="$1"
45
+ local message="$2"
46
+
47
+ if [[ -z "$agent_name" ]] || [[ -z "$message" ]]; then
48
+ echo "Usage: tg_agent <agent_name> <message>"
49
+ return 1
50
+ fi
51
+
52
+ # Use tmux session name as message ID (much simpler!)
53
+ local tmux_session="$(tmux display-message -p '#S' 2>/dev/null || echo 'unknown')"
54
+ local msg_id="${tmux_session}"
55
+
56
+ # Record mapping for listener daemon (backward compatibility)
57
+ # NOTE: New routing doesn't require this, but kept for transition period
58
+ mkdir -p "$HOME/.telemux/message_queue"
59
+ echo "${msg_id}:${agent_name}:${tmux_session}:$(date -Iseconds)" >> "$HOME/.telemux/message_queue/outgoing.log"
60
+
61
+ # Send to Telegram with identifier
62
+ curl -s -X POST "https://api.telegram.org/bot${TELEMUX_TG_BOT_TOKEN}/sendMessage" \
63
+ -d chat_id="${TELEMUX_TG_CHAT_ID}" \
64
+ -d text="[>] <b>[${agent_name}:${msg_id}]</b>
65
+
66
+ ${message}
67
+
68
+ <i>Reply with: ${msg_id}: your response</i>" \
69
+ -d parse_mode="HTML" > /dev/null && echo "Agent alert sent: ${msg_id}"
70
+
71
+ echo "$msg_id" # Return message ID
72
+ }
73
+
74
+ # Alert when command completes
75
+ tg_done() {
76
+ local exit_code=$?
77
+ local cmd
78
+
79
+ # Bash-compatible history access
80
+ if [ -n "$BASH_VERSION" ]; then
81
+ cmd="$(fc -ln -1 2>/dev/null || echo 'unknown command')"
82
+ else
83
+ # zsh
84
+ cmd="${history[$((HISTCMD-1))]}"
85
+ fi
86
+
87
+ # Trim leading/trailing whitespace
88
+ cmd="$(echo "$cmd" | xargs)"
89
+
90
+ if [[ $exit_code -eq 0 ]]; then
91
+ tg_alert "Command completed: ${cmd}"
92
+ else
93
+ tg_alert "Command failed (exit $exit_code): ${cmd}"
94
+ fi
95
+ }
96
+
97
+ # Control aliases (defined here for consistency)
98
+ # Uses Python-based telemux commands
99
+ alias tg-start="telemux-start"
100
+ alias tg-stop="telemux-stop"
101
+ alias tg-restart="telemux-restart"
102
+ alias tg-status="telemux-status"
103
+ alias tg-logs="telemux-logs"
104
+ alias tg-attach="telemux-attach"
105
+ alias tg-cleanup="telemux-cleanup"
106
+ alias tg-doctor="telemux-doctor"
107
+
108
+ # Backward compatibility aliases (for users upgrading from old Team Mux)
109
+ alias alert_telegram="tg_alert"
110
+ alias agent_telegram="tg_agent"