telemux 1.0.5__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.
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 typing import Dict, List, Optional, Tuple, Any
17
+
18
+ from . import TELEMUX_DIR, MESSAGE_QUEUE_DIR, LOG_FILE
19
+ from .config import load_config
20
+
21
+ # Message queue files
22
+ OUTGOING_LOG = MESSAGE_QUEUE_DIR / "outgoing.log"
23
+ INCOMING_LOG = MESSAGE_QUEUE_DIR / "incoming.log"
24
+ LISTENER_STATE = MESSAGE_QUEUE_DIR / "listener_state.json"
25
+
26
+ # Logging setup
27
+ ERROR_LOG_FILE = TELEMUX_DIR / "telegram_errors.log"
28
+
29
+ # Get log level from environment variable (default: INFO)
30
+ LOG_LEVEL = os.environ.get('TELEMUX_LOG_LEVEL', 'INFO').upper()
31
+ LOG_LEVEL_MAP = {
32
+ 'DEBUG': logging.DEBUG,
33
+ 'INFO': logging.INFO,
34
+ 'WARNING': logging.WARNING,
35
+ 'ERROR': logging.ERROR,
36
+ 'CRITICAL': logging.CRITICAL
37
+ }
38
+
39
+ # Configure logging with multiple handlers
40
+ logger = logging.getLogger('TelegramListener')
41
+ logger.setLevel(LOG_LEVEL_MAP.get(LOG_LEVEL, logging.INFO))
42
+
43
+ # Main log file handler (all levels)
44
+ main_handler = logging.FileHandler(LOG_FILE)
45
+ main_handler.setLevel(logging.DEBUG)
46
+ main_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
47
+ main_handler.setFormatter(main_formatter)
48
+
49
+ # Error log file handler (errors only)
50
+ error_handler = logging.FileHandler(ERROR_LOG_FILE)
51
+ error_handler.setLevel(logging.ERROR)
52
+ error_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s')
53
+ error_handler.setFormatter(error_formatter)
54
+
55
+ # Console handler (configurable level)
56
+ console_handler = logging.StreamHandler()
57
+ console_handler.setLevel(LOG_LEVEL_MAP.get(LOG_LEVEL, logging.INFO))
58
+ console_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
59
+ console_handler.setFormatter(console_formatter)
60
+
61
+ logger.addHandler(main_handler)
62
+ logger.addHandler(error_handler)
63
+ logger.addHandler(console_handler)
64
+
65
+
66
+ def load_state() -> Dict:
67
+ """Load listener state (last update ID)"""
68
+ if LISTENER_STATE.exists():
69
+ with open(LISTENER_STATE) as f:
70
+ return json.load(f)
71
+ return {"last_update_id": 0}
72
+
73
+
74
+ def save_state(state: Dict):
75
+ """Save listener state"""
76
+ MESSAGE_QUEUE_DIR.mkdir(parents=True, exist_ok=True)
77
+ with open(LISTENER_STATE, 'w') as f:
78
+ json.dump(state, f, indent=2)
79
+
80
+
81
+ def get_telegram_updates(bot_token: str, offset: int = 0, max_retries: int = 3) -> List[Dict]:
82
+ """Poll Telegram for new messages with retry logic"""
83
+ url = f"https://api.telegram.org/bot{bot_token}/getUpdates"
84
+ params = {
85
+ "offset": offset,
86
+ "timeout": 30 # Long polling
87
+ }
88
+
89
+ for attempt in range(max_retries):
90
+ try:
91
+ response = requests.get(url, params=params, timeout=35)
92
+ response.raise_for_status()
93
+ data = response.json()
94
+
95
+ if data.get("ok"):
96
+ return data.get("result", [])
97
+ else:
98
+ logger.warning(f"Telegram API returned not ok: {data}")
99
+ return []
100
+
101
+ except requests.exceptions.Timeout:
102
+ # Timeout is expected with long polling, only log if it's a problem
103
+ if attempt < max_retries - 1:
104
+ logger.debug(f"Telegram long-poll timeout (attempt {attempt + 1}/{max_retries})")
105
+ time.sleep(2 ** attempt)
106
+ else:
107
+ logger.warning(f"Failed to get updates after {max_retries} timeout attempts")
108
+ return []
109
+
110
+ except requests.exceptions.ConnectionError as e:
111
+ logger.warning(f"Connection error (attempt {attempt + 1}/{max_retries}): {e}")
112
+ if attempt < max_retries - 1:
113
+ time.sleep(2 ** attempt) # Exponential backoff
114
+ else:
115
+ logger.error(f"Failed to connect after {max_retries} attempts. Is the network down?")
116
+ return []
117
+
118
+ except requests.exceptions.RequestException as e:
119
+ logger.warning(f"Request error (attempt {attempt + 1}/{max_retries}): {e}")
120
+ if attempt < max_retries - 1:
121
+ time.sleep(2 ** attempt)
122
+ else:
123
+ logger.error(f"Failed to get updates after {max_retries} attempts: {e}")
124
+ return []
125
+
126
+ except Exception as e:
127
+ logger.error(f"Unexpected error getting Telegram updates: {e}")
128
+ return []
129
+
130
+ return []
131
+
132
+
133
+ def send_telegram_message(bot_token: str, chat_id: str, text: str, max_retries: int = 3):
134
+ """Send a message to Telegram with retry logic"""
135
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
136
+ payload = {
137
+ "chat_id": chat_id,
138
+ "text": text,
139
+ "parse_mode": "HTML"
140
+ }
141
+
142
+ for attempt in range(max_retries):
143
+ try:
144
+ response = requests.post(url, json=payload, timeout=10)
145
+ response.raise_for_status()
146
+ logger.info(f"Sent message to Telegram: {text[:50]}...")
147
+ return True
148
+
149
+ except requests.exceptions.Timeout:
150
+ logger.warning(f"Telegram API timeout (attempt {attempt + 1}/{max_retries})")
151
+ if attempt < max_retries - 1:
152
+ time.sleep(2 ** attempt) # Exponential backoff: 1s, 2s, 4s
153
+ else:
154
+ logger.error(f"Failed to send message after {max_retries} attempts (timeout)")
155
+ return False
156
+
157
+ except requests.exceptions.RequestException as e:
158
+ logger.warning(f"Telegram API error (attempt {attempt + 1}/{max_retries}): {e}")
159
+ if attempt < max_retries - 1:
160
+ time.sleep(2 ** attempt) # Exponential backoff
161
+ else:
162
+ logger.error(f"Failed to send message after {max_retries} attempts: {e}")
163
+ return False
164
+
165
+ except Exception as e:
166
+ logger.error(f"Unexpected error sending Telegram message: {e}")
167
+ return False
168
+
169
+ return False
170
+
171
+
172
+ def parse_message_id(text: str) -> Optional[Tuple[str, str]]:
173
+ """
174
+ Parse message ID and response from text
175
+ Expected formats:
176
+ - session-name: Your response here (new format)
177
+ - msg-1234567890-12345: Your response here (old format)
178
+ Returns: (message_id, response) or None
179
+ """
180
+ # Match either session name (letters, numbers, dashes, underscores) or old msg format
181
+ pattern = r'^([\w-]+):\s*(.+)$'
182
+ match = re.match(pattern, text, re.DOTALL)
183
+ if match:
184
+ return match.group(1), match.group(2)
185
+ return None
186
+
187
+
188
+ def process_update(update: Dict[str, Any], bot_token: str, chat_id: str) -> None:
189
+ """Process a single Telegram update - SESSION-BASED ROUTING
190
+
191
+ Args:
192
+ update: Telegram update dict containing message data
193
+ bot_token: Telegram bot token
194
+ chat_id: Telegram chat ID
195
+
196
+ Returns:
197
+ None
198
+ """
199
+ if "message" not in update:
200
+ return
201
+
202
+ message = update["message"]
203
+ text = message.get("text", "")
204
+ from_user = message.get("from", {}).get("first_name", "Unknown")
205
+
206
+ logger.info(f"Received message from {from_user}: {text[:50]}...")
207
+
208
+ # Parse message for session: message format
209
+ parsed = parse_message_id(text)
210
+ if not parsed:
211
+ logger.info("Message doesn't match format (session-name: message), ignoring")
212
+ return
213
+
214
+ session_name, response = parsed
215
+ logger.info(f"Parsed message - Target session: {session_name}")
216
+
217
+ # Check if tmux session exists
218
+ try:
219
+ result = subprocess.run(
220
+ ['tmux', 'list-sessions', '-F', '#{session_name}'],
221
+ capture_output=True,
222
+ text=True,
223
+ check=False
224
+ )
225
+
226
+ if result.returncode != 0:
227
+ # No tmux sessions at all
228
+ logger.warning("No tmux sessions found")
229
+ send_telegram_message(bot_token, chat_id, "No tmux sessions are running")
230
+ return
231
+
232
+ active_sessions = [s for s in result.stdout.strip().split('\n') if s]
233
+
234
+ if session_name not in active_sessions:
235
+ logger.warning(f"Tmux session not found: {session_name}")
236
+ # Security: Show count only, not all session names
237
+ msg = (f"Session <b>{session_name}</b> not found. "
238
+ f"{len(active_sessions)} active session(s).")
239
+ send_telegram_message(bot_token, chat_id, msg)
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: tg_agent \"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, "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, "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,117 @@
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 or ~/.bashrc (via telemux install)
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 message="$*"
45
+
46
+ if [[ -z "$message" ]]; then
47
+ echo "Usage: tg_agent <message>"
48
+ return 1
49
+ fi
50
+
51
+ # Auto-detect tmux session name
52
+ local tmux_session="$(tmux display-message -p '#S' 2>/dev/null || echo 'unknown')"
53
+ local agent_name="${tmux_session}"
54
+ local msg_id="${tmux_session}"
55
+
56
+ # Check if we're in a tmux session
57
+ if [[ -z "$TMUX" ]]; then
58
+ echo "Warning: Not in a tmux session. Using 'terminal' as session name."
59
+ agent_name="terminal"
60
+ msg_id="terminal"
61
+ fi
62
+
63
+ # Record mapping for listener daemon (backward compatibility)
64
+ # NOTE: New routing doesn't require this, but kept for transition period
65
+ mkdir -p "$HOME/.telemux/message_queue"
66
+ echo "${msg_id}:${agent_name}:${tmux_session}:$(date -Iseconds)" >> "$HOME/.telemux/message_queue/outgoing.log"
67
+
68
+ # Send to Telegram with identifier
69
+ curl -s -X POST "https://api.telegram.org/bot${TELEMUX_TG_BOT_TOKEN}/sendMessage" \
70
+ -d chat_id="${TELEMUX_TG_CHAT_ID}" \
71
+ -d text="[>] <b>[${agent_name}]</b>
72
+
73
+ ${message}
74
+
75
+ <i>Reply with: ${msg_id}: your response</i>" \
76
+ -d parse_mode="HTML" > /dev/null && echo "Agent message sent from: ${msg_id}"
77
+
78
+ echo "$msg_id" # Return message ID
79
+ }
80
+
81
+ # Alert when command completes
82
+ tg_done() {
83
+ local exit_code=$?
84
+ local cmd
85
+
86
+ # Bash-compatible history access
87
+ if [ -n "$BASH_VERSION" ]; then
88
+ cmd="$(fc -ln -1 2>/dev/null || echo 'unknown command')"
89
+ else
90
+ # zsh
91
+ cmd="${history[$((HISTCMD-1))]}"
92
+ fi
93
+
94
+ # Trim leading/trailing whitespace
95
+ cmd="$(echo "$cmd" | xargs)"
96
+
97
+ if [[ $exit_code -eq 0 ]]; then
98
+ tg_alert "Command completed: ${cmd}"
99
+ else
100
+ tg_alert "Command failed (exit $exit_code): ${cmd}"
101
+ fi
102
+ }
103
+
104
+ # Control aliases (defined here for consistency)
105
+ # Uses Python-based telemux commands
106
+ alias tg-start="telemux-start"
107
+ alias tg-stop="telemux-stop"
108
+ alias tg-restart="telemux-restart"
109
+ alias tg-status="telemux-status"
110
+ alias tg-logs="telemux-logs"
111
+ alias tg-attach="telemux-attach"
112
+ alias tg-cleanup="telemux-cleanup"
113
+ alias tg-doctor="telemux-doctor"
114
+
115
+ # I added these
116
+ alias tg-alert="tg_alert"
117
+ alias tg-msg="tg_agent"