instar 0.3.7 → 0.4.0

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.
@@ -72,6 +72,48 @@ async function respawnSessionForTopic(sessionManager, telegram, targetSession, t
72
72
  await telegram.sendToTopic(topicId, `Session respawned.`);
73
73
  console.log(`[telegram→session] Respawned "${newSessionName}" for topic ${topicId}`);
74
74
  }
75
+ /**
76
+ * Wire up Telegram session management callbacks.
77
+ * These enable /interrupt, /restart, /sessions commands and stall detection.
78
+ */
79
+ function wireTelegramCallbacks(telegram, sessionManager, state) {
80
+ // /interrupt — send Escape key to a tmux session
81
+ telegram.onInterruptSession = async (sessionName) => {
82
+ try {
83
+ execFileSync(detectTmuxPath(), ['send-keys', '-t', `=${sessionName}:`, 'Escape'], {
84
+ encoding: 'utf-8', timeout: 5000,
85
+ });
86
+ return true;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ };
92
+ // /restart — kill session and respawn
93
+ telegram.onRestartSession = async (sessionName, topicId) => {
94
+ // Kill existing session
95
+ try {
96
+ execFileSync(detectTmuxPath(), ['kill-session', '-t', `=${sessionName}`], { stdio: 'ignore' });
97
+ }
98
+ catch { /* may already be dead */ }
99
+ // Respawn with thread history
100
+ await respawnSessionForTopic(sessionManager, telegram, sessionName, topicId);
101
+ };
102
+ // /sessions — list running sessions
103
+ telegram.onListSessions = () => {
104
+ const sessions = state.listSessions({ status: 'running' });
105
+ return sessions.map(s => ({
106
+ name: s.name,
107
+ tmuxSession: s.tmuxSession,
108
+ status: s.status,
109
+ alive: sessionManager.isSessionAlive(s.tmuxSession),
110
+ }));
111
+ };
112
+ // Stall detection — check if a session is alive
113
+ telegram.onIsSessionAlive = (sessionName) => {
114
+ return sessionManager.isSessionAlive(sessionName);
115
+ };
116
+ }
75
117
  /**
76
118
  * Wire up Telegram message routing: topic messages → Claude sessions.
77
119
  * This is the core handler that makes Telegram topics work like sessions.
@@ -82,7 +124,8 @@ function wireTelegramRouting(telegram, sessionManager) {
82
124
  if (!topicId)
83
125
  return;
84
126
  const text = msg.content;
85
- // Handle /new command spawn a new session with its own topic
127
+ // Most commands are handled inside TelegramAdapter.handleCommand().
128
+ // /new is handled here because it needs sessionManager access.
86
129
  const newMatch = text.match(/^\/new(?:\s+(.+))?$/);
87
130
  if (newMatch) {
88
131
  const sessionName = newMatch[1]?.trim() || null;
@@ -110,6 +153,8 @@ function wireTelegramRouting(telegram, sessionManager) {
110
153
  if (sessionManager.isSessionAlive(targetSession)) {
111
154
  console.log(`[telegram→session] Injecting into ${targetSession}: "${text.slice(0, 80)}"`);
112
155
  sessionManager.injectTelegramMessage(targetSession, topicId, text);
156
+ // Track for stall detection
157
+ telegram.trackMessageInjection(topicId, targetSession, text);
113
158
  }
114
159
  else {
115
160
  // Session died — respawn with thread history
@@ -235,8 +280,9 @@ export async function startServer(options) {
235
280
  telegram = new TelegramAdapter(telegramConfig.config, config.stateDir);
236
281
  await telegram.start();
237
282
  console.log(pc.green(' Telegram connected'));
238
- // Wire up topic → session routing
283
+ // Wire up topic → session routing and session management callbacks
239
284
  wireTelegramRouting(telegram, sessionManager);
285
+ wireTelegramCallbacks(telegram, sessionManager, state);
240
286
  console.log(pc.green(' Telegram message routing active'));
241
287
  if (scheduler) {
242
288
  scheduler.setMessenger(telegram);
@@ -156,8 +156,8 @@ export interface MessagingAdapter {
156
156
  start(): Promise<void>;
157
157
  /** Stop listening */
158
158
  stop(): Promise<void>;
159
- /** Send a message to a user */
160
- send(message: OutgoingMessage): Promise<void>;
159
+ /** Send a message to a user. Returns platform-specific delivery info. */
160
+ send(message: OutgoingMessage): Promise<void | unknown>;
161
161
  /** Register a handler for incoming messages */
162
162
  onMessage(handler: (message: Message) => Promise<void>): void;
163
163
  /** Resolve a platform-specific identifier to a user ID */
@@ -2,8 +2,9 @@
2
2
  * Telegram Messaging Adapter — send/receive messages via Telegram Bot API.
3
3
  *
4
4
  * Uses long polling to receive messages. Supports forum topics
5
- * (each user gets a topic thread). Includes topic-session registry
6
- * and message logging for session respawn with thread history.
5
+ * (each user gets a topic thread). Includes topic-session registry,
6
+ * message logging, voice transcription, photo handling, stall detection,
7
+ * auth gating, and delivery confirmation.
7
8
  *
8
9
  * No external dependencies — uses native fetch for Telegram API calls.
9
10
  */
@@ -15,6 +16,18 @@ export interface TelegramConfig {
15
16
  chatId: string;
16
17
  /** Polling interval in ms */
17
18
  pollIntervalMs?: number;
19
+ /** Authorized Telegram user IDs (only these users' messages are processed) */
20
+ authorizedUserIds?: number[];
21
+ /** Voice transcription provider: 'groq' or 'openai' (auto-detects if not set) */
22
+ voiceProvider?: string;
23
+ /** Stall detection timeout in minutes (default: 5, 0 to disable) */
24
+ stallTimeoutMinutes?: number;
25
+ }
26
+ export interface SendResult {
27
+ /** Telegram message ID */
28
+ messageId: number;
29
+ /** Topic the message was sent to */
30
+ topicId?: number;
18
31
  }
19
32
  interface LogEntry {
20
33
  messageId: number;
@@ -31,21 +44,36 @@ export declare class TelegramAdapter implements MessagingAdapter {
31
44
  private polling;
32
45
  private pollTimeout;
33
46
  private lastUpdateId;
47
+ private startedAt;
48
+ private consecutivePollErrors;
34
49
  private topicToSession;
35
50
  private sessionToTopic;
36
51
  private topicToName;
37
52
  private registryPath;
38
53
  private messageLogPath;
39
54
  private offsetPath;
55
+ private stateDir;
56
+ private pendingMessages;
57
+ private stallCheckInterval;
40
58
  onTopicMessage: ((message: Message) => void) | null;
59
+ onInterruptSession: ((sessionName: string) => Promise<boolean>) | null;
60
+ onRestartSession: ((sessionName: string, topicId: number) => Promise<void>) | null;
61
+ onListSessions: (() => Array<{
62
+ name: string;
63
+ tmuxSession: string;
64
+ status: string;
65
+ alive: boolean;
66
+ }>) | null;
67
+ onIsSessionAlive: ((tmuxSession: string) => boolean) | null;
41
68
  constructor(config: TelegramConfig, stateDir: string);
42
69
  start(): Promise<void>;
43
70
  stop(): Promise<void>;
44
- send(message: OutgoingMessage): Promise<void>;
71
+ send(message: OutgoingMessage): Promise<SendResult>;
45
72
  /**
46
73
  * Send a message to a specific forum topic.
74
+ * Returns the Telegram message ID for delivery confirmation.
47
75
  */
48
- sendToTopic(topicId: number, text: string): Promise<void>;
76
+ sendToTopic(topicId: number, text: string): Promise<SendResult>;
49
77
  /**
50
78
  * Create a forum topic in the supergroup.
51
79
  */
@@ -53,9 +81,19 @@ export declare class TelegramAdapter implements MessagingAdapter {
53
81
  topicId: number;
54
82
  name: string;
55
83
  }>;
84
+ /**
85
+ * Close a forum topic.
86
+ */
87
+ closeForumTopic(topicId: number): Promise<boolean>;
56
88
  onMessage(handler: (message: Message) => Promise<void>): void;
57
89
  resolveUser(channelIdentifier: string): Promise<string | null>;
90
+ /**
91
+ * Check if a message is from an authorized user.
92
+ * If no authorizedUserIds configured, all messages are accepted.
93
+ */
94
+ private isAuthorized;
58
95
  registerTopicSession(topicId: number, sessionName: string): void;
96
+ unregisterTopic(topicId: number): void;
59
97
  getSessionForTopic(topicId: number): string | null;
60
98
  getTopicForSession(sessionName: string): number | null;
61
99
  getTopicName(topicId: number): string | null;
@@ -67,6 +105,41 @@ export declare class TelegramAdapter implements MessagingAdapter {
67
105
  sessionName: string;
68
106
  topicName: string | null;
69
107
  }>;
108
+ /**
109
+ * Track that a message was injected into a session.
110
+ * Used by stall detection to alert if no response comes back.
111
+ */
112
+ trackMessageInjection(topicId: number, sessionName: string, messageText: string): void;
113
+ private clearStallForTopic;
114
+ private checkForStalls;
115
+ getStatus(): {
116
+ started: boolean;
117
+ uptime: number | null;
118
+ pendingStalls: number;
119
+ topicMappings: number;
120
+ };
121
+ /**
122
+ * Download a file from Telegram by file_id.
123
+ */
124
+ private downloadFile;
125
+ /**
126
+ * Resolve voice transcription provider from config or environment.
127
+ * Checks explicit config, then env vars, then auto-detects.
128
+ */
129
+ private resolveTranscriptionProvider;
130
+ /**
131
+ * Transcribe a voice message using the configured provider.
132
+ */
133
+ private transcribeVoice;
134
+ /**
135
+ * Download a photo from Telegram and save it locally.
136
+ * Returns the local file path.
137
+ */
138
+ private downloadPhoto;
139
+ /**
140
+ * Process Telegram commands. Returns true if the message was a command.
141
+ */
142
+ private handleCommand;
70
143
  /**
71
144
  * Get recent messages for a topic (for thread history on respawn).
72
145
  */
@@ -74,13 +147,25 @@ export declare class TelegramAdapter implements MessagingAdapter {
74
147
  private appendToLog;
75
148
  /** Keep only the last 75,000 lines when log exceeds 100,000 lines.
76
149
  * High limits because message history is core agent memory.
77
- * At ~200 bytes/line average, 100k lines 20MB — fine for a dedicated machine. */
150
+ * At ~200 bytes/line average, 100k lines ~ 20MB — fine for a dedicated machine. */
78
151
  private maybeRotateLog;
79
152
  private loadRegistry;
80
153
  private saveRegistry;
81
154
  private loadOffset;
82
155
  private saveOffset;
83
156
  private poll;
157
+ /**
158
+ * Process a single Telegram update (text, voice, or photo).
159
+ */
160
+ private processUpdate;
161
+ /**
162
+ * Handle an incoming voice message: download, transcribe, route as text.
163
+ */
164
+ private handleVoiceMessage;
165
+ /**
166
+ * Handle an incoming photo message: download, save, route with path.
167
+ */
168
+ private handlePhotoMessage;
84
169
  private getUpdates;
85
170
  private apiCall;
86
171
  }
@@ -2,8 +2,9 @@
2
2
  * Telegram Messaging Adapter — send/receive messages via Telegram Bot API.
3
3
  *
4
4
  * Uses long polling to receive messages. Supports forum topics
5
- * (each user gets a topic thread). Includes topic-session registry
6
- * and message logging for session respawn with thread history.
5
+ * (each user gets a topic thread). Includes topic-session registry,
6
+ * message logging, voice transcription, photo handling, stall detection,
7
+ * auth gating, and delivery confirmation.
7
8
  *
8
9
  * No external dependencies — uses native fetch for Telegram API calls.
9
10
  */
@@ -16,6 +17,8 @@ export class TelegramAdapter {
16
17
  polling = false;
17
18
  pollTimeout = null;
18
19
  lastUpdateId = 0;
20
+ startedAt = null;
21
+ consecutivePollErrors = 0;
19
22
  // Topic-session registry (persisted to disk)
20
23
  topicToSession = new Map();
21
24
  sessionToTopic = new Map();
@@ -23,10 +26,20 @@ export class TelegramAdapter {
23
26
  registryPath;
24
27
  messageLogPath;
25
28
  offsetPath;
29
+ stateDir;
30
+ // Stall detection
31
+ pendingMessages = new Map(); // key = topicId-timestamp
32
+ stallCheckInterval = null;
26
33
  // Topic message callback — fires on every incoming topic message
27
34
  onTopicMessage = null;
35
+ // Session management callbacks (wired by server.ts)
36
+ onInterruptSession = null;
37
+ onRestartSession = null;
38
+ onListSessions = null;
39
+ onIsSessionAlive = null;
28
40
  constructor(config, stateDir) {
29
41
  this.config = config;
42
+ this.stateDir = stateDir;
30
43
  this.registryPath = path.join(stateDir, 'topic-session-registry.json');
31
44
  this.messageLogPath = path.join(stateDir, 'telegram-messages.jsonl');
32
45
  this.offsetPath = path.join(stateDir, 'telegram-poll-offset.json');
@@ -37,8 +50,15 @@ export class TelegramAdapter {
37
50
  if (this.polling)
38
51
  return;
39
52
  this.polling = true;
53
+ this.startedAt = new Date();
54
+ this.consecutivePollErrors = 0;
40
55
  console.log(`[telegram] Starting long-polling...`);
41
56
  this.poll();
57
+ // Start stall detection if configured
58
+ const stallMinutes = this.config.stallTimeoutMinutes ?? 5;
59
+ if (stallMinutes > 0) {
60
+ this.stallCheckInterval = setInterval(() => this.checkForStalls(), 30_000); // Check every 30s
61
+ }
42
62
  }
43
63
  async stop() {
44
64
  this.polling = false;
@@ -46,6 +66,10 @@ export class TelegramAdapter {
46
66
  clearTimeout(this.pollTimeout);
47
67
  this.pollTimeout = null;
48
68
  }
69
+ if (this.stallCheckInterval) {
70
+ clearInterval(this.stallCheckInterval);
71
+ this.stallCheckInterval = null;
72
+ }
49
73
  }
50
74
  async send(message) {
51
75
  const topicId = message.channel?.identifier;
@@ -58,22 +82,23 @@ export class TelegramAdapter {
58
82
  params.message_thread_id = parseInt(topicId, 10);
59
83
  }
60
84
  try {
61
- await this.apiCall('sendMessage', params);
85
+ const result = await this.apiCall('sendMessage', params);
86
+ return { messageId: result.message_id, topicId: topicId ? parseInt(topicId, 10) : undefined };
62
87
  }
63
88
  catch (err) {
64
89
  // Only retry without parse_mode on 400 errors (likely Markdown parse failures)
65
90
  const errMsg = err instanceof Error ? err.message : String(err);
66
91
  if (errMsg.includes('(400)') && params.parse_mode) {
67
92
  delete params.parse_mode;
68
- await this.apiCall('sendMessage', params);
69
- }
70
- else {
71
- throw err;
93
+ const result = await this.apiCall('sendMessage', params);
94
+ return { messageId: result.message_id, topicId: topicId ? parseInt(topicId, 10) : undefined };
72
95
  }
96
+ throw err;
73
97
  }
74
98
  }
75
99
  /**
76
100
  * Send a message to a specific forum topic.
101
+ * Returns the Telegram message ID for delivery confirmation.
77
102
  */
78
103
  async sendToTopic(topicId, text) {
79
104
  const params = {
@@ -84,21 +109,25 @@ export class TelegramAdapter {
84
109
  if (topicId > 1) {
85
110
  params.message_thread_id = topicId;
86
111
  }
112
+ let result;
87
113
  try {
88
- await this.apiCall('sendMessage', { ...params, parse_mode: 'Markdown' });
114
+ result = await this.apiCall('sendMessage', { ...params, parse_mode: 'Markdown' });
89
115
  }
90
116
  catch {
91
- await this.apiCall('sendMessage', params);
117
+ result = await this.apiCall('sendMessage', params);
92
118
  }
93
119
  // Log outbound messages too
94
120
  this.appendToLog({
95
- messageId: 0,
121
+ messageId: result.message_id,
96
122
  topicId,
97
123
  text,
98
124
  fromUser: false,
99
125
  timestamp: new Date().toISOString(),
100
126
  sessionName: this.topicToSession.get(topicId) ?? null,
101
127
  });
128
+ // Clear stall tracking for this topic (agent responded)
129
+ this.clearStallForTopic(topicId);
130
+ return { messageId: result.message_id, topicId };
102
131
  }
103
132
  /**
104
133
  * Create a forum topic in the supergroup.
@@ -117,12 +146,38 @@ export class TelegramAdapter {
117
146
  console.log(`[telegram] Created forum topic: "${name}" (ID: ${result.message_thread_id})`);
118
147
  return { topicId: result.message_thread_id, name: result.name };
119
148
  }
149
+ /**
150
+ * Close a forum topic.
151
+ */
152
+ async closeForumTopic(topicId) {
153
+ try {
154
+ await this.apiCall('closeForumTopic', {
155
+ chat_id: this.config.chatId,
156
+ message_thread_id: topicId,
157
+ });
158
+ return true;
159
+ }
160
+ catch {
161
+ return false;
162
+ }
163
+ }
120
164
  onMessage(handler) {
121
165
  this.handler = handler;
122
166
  }
123
167
  async resolveUser(channelIdentifier) {
124
168
  return null;
125
169
  }
170
+ // ── Auth Gating ──────────────────────────────────────────
171
+ /**
172
+ * Check if a message is from an authorized user.
173
+ * If no authorizedUserIds configured, all messages are accepted.
174
+ */
175
+ isAuthorized(userId) {
176
+ const authorized = this.config.authorizedUserIds;
177
+ if (!authorized || authorized.length === 0)
178
+ return true;
179
+ return authorized.includes(userId);
180
+ }
126
181
  // ── Topic-Session Registry ─────────────────────────────────
127
182
  registerTopicSession(topicId, sessionName) {
128
183
  this.topicToSession.set(topicId, sessionName);
@@ -130,6 +185,13 @@ export class TelegramAdapter {
130
185
  this.saveRegistry();
131
186
  console.log(`[telegram] Registered topic ${topicId} <-> session "${sessionName}"`);
132
187
  }
188
+ unregisterTopic(topicId) {
189
+ const sessionName = this.topicToSession.get(topicId);
190
+ this.topicToSession.delete(topicId);
191
+ if (sessionName)
192
+ this.sessionToTopic.delete(sessionName);
193
+ this.saveRegistry();
194
+ }
133
195
  getSessionForTopic(topicId) {
134
196
  return this.topicToSession.get(topicId) ?? null;
135
197
  }
@@ -153,6 +215,308 @@ export class TelegramAdapter {
153
215
  }
154
216
  return result;
155
217
  }
218
+ // ── Stall Detection ──────────────────────────────────────
219
+ /**
220
+ * Track that a message was injected into a session.
221
+ * Used by stall detection to alert if no response comes back.
222
+ */
223
+ trackMessageInjection(topicId, sessionName, messageText) {
224
+ const key = `${topicId}-${Date.now()}`;
225
+ this.pendingMessages.set(key, {
226
+ topicId,
227
+ sessionName,
228
+ messageText: messageText.slice(0, 100),
229
+ injectedAt: Date.now(),
230
+ alerted: false,
231
+ });
232
+ }
233
+ clearStallForTopic(topicId) {
234
+ for (const [key, pending] of this.pendingMessages) {
235
+ if (pending.topicId === topicId) {
236
+ this.pendingMessages.delete(key);
237
+ }
238
+ }
239
+ }
240
+ checkForStalls() {
241
+ const stallMinutes = this.config.stallTimeoutMinutes ?? 5;
242
+ const stallThresholdMs = stallMinutes * 60 * 1000;
243
+ const now = Date.now();
244
+ for (const [key, pending] of this.pendingMessages) {
245
+ if (pending.alerted)
246
+ continue;
247
+ if (now - pending.injectedAt < stallThresholdMs)
248
+ continue;
249
+ // Check if session is still alive
250
+ const alive = this.onIsSessionAlive
251
+ ? this.onIsSessionAlive(pending.sessionName)
252
+ : true; // assume alive if no checker
253
+ pending.alerted = true;
254
+ const status = alive ? 'running but not responding' : 'no longer running';
255
+ const minutesAgo = Math.round((now - pending.injectedAt) / 60000);
256
+ this.sendToTopic(pending.topicId, `\u26a0\ufe0f No response after ${minutesAgo} minutes. Session "${pending.sessionName}" is ${status}.\n\nMessage: "${pending.messageText}..."${alive ? '\n\nTry /interrupt to unstick, or /restart to respawn.' : '\n\nSend another message to auto-respawn.'}`).catch(err => {
257
+ console.error(`[telegram] Stall alert failed: ${err}`);
258
+ });
259
+ }
260
+ // Clean up old entries (older than 30 minutes, already alerted)
261
+ for (const [key, pending] of this.pendingMessages) {
262
+ if (pending.alerted && now - pending.injectedAt > 30 * 60 * 1000) {
263
+ this.pendingMessages.delete(key);
264
+ }
265
+ }
266
+ }
267
+ // ── Health Status ────────────────────────────────────────
268
+ getStatus() {
269
+ return {
270
+ started: this.polling,
271
+ uptime: this.startedAt ? Date.now() - this.startedAt.getTime() : null,
272
+ pendingStalls: this.pendingMessages.size,
273
+ topicMappings: this.topicToSession.size,
274
+ };
275
+ }
276
+ // ── Voice Transcription ──────────────────────────────────
277
+ /**
278
+ * Download a file from Telegram by file_id.
279
+ */
280
+ async downloadFile(fileId, destPath) {
281
+ const fileInfo = await this.apiCall('getFile', { file_id: fileId });
282
+ const fileUrl = `https://api.telegram.org/file/bot${this.config.token}/${fileInfo.file_path}`;
283
+ const controller = new AbortController();
284
+ const timer = setTimeout(() => controller.abort(), 60_000);
285
+ try {
286
+ const response = await fetch(fileUrl, { signal: controller.signal });
287
+ if (!response.ok)
288
+ throw new Error(`Download failed: ${response.status}`);
289
+ const buffer = Buffer.from(await response.arrayBuffer());
290
+ fs.writeFileSync(destPath, buffer);
291
+ }
292
+ finally {
293
+ clearTimeout(timer);
294
+ }
295
+ }
296
+ /**
297
+ * Resolve voice transcription provider from config or environment.
298
+ * Checks explicit config, then env vars, then auto-detects.
299
+ */
300
+ resolveTranscriptionProvider() {
301
+ const providers = {
302
+ groq: {
303
+ envKey: 'GROQ_API_KEY',
304
+ baseUrl: 'https://api.groq.com/openai/v1',
305
+ model: 'whisper-large-v3',
306
+ },
307
+ openai: {
308
+ envKey: 'OPENAI_API_KEY',
309
+ baseUrl: 'https://api.openai.com/v1',
310
+ model: 'whisper-1',
311
+ },
312
+ };
313
+ // Check explicit config
314
+ const explicit = this.config.voiceProvider?.toLowerCase();
315
+ if (explicit && providers[explicit]) {
316
+ const p = providers[explicit];
317
+ const apiKey = process.env[p.envKey];
318
+ if (!apiKey) {
319
+ console.warn(`[telegram] ${p.envKey} not set — required for ${explicit} voice transcription`);
320
+ return null;
321
+ }
322
+ return { apiKey, baseUrl: p.baseUrl, model: p.model };
323
+ }
324
+ // Auto-detect: try Groq first (cheaper), then OpenAI
325
+ for (const [name, p] of Object.entries(providers)) {
326
+ const apiKey = process.env[p.envKey];
327
+ if (apiKey) {
328
+ console.log(`[telegram] Auto-detected voice transcription provider: ${name}`);
329
+ return { apiKey, baseUrl: p.baseUrl, model: p.model };
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+ /**
335
+ * Transcribe a voice message using the configured provider.
336
+ */
337
+ async transcribeVoice(filePath) {
338
+ const provider = this.resolveTranscriptionProvider();
339
+ if (!provider) {
340
+ throw new Error('No voice transcription provider configured. Set GROQ_API_KEY or OPENAI_API_KEY.');
341
+ }
342
+ const formData = new FormData();
343
+ const fileBuffer = fs.readFileSync(filePath);
344
+ const blob = new Blob([fileBuffer], { type: 'audio/ogg' });
345
+ formData.append('file', blob, path.basename(filePath));
346
+ formData.append('model', provider.model);
347
+ const controller = new AbortController();
348
+ const timer = setTimeout(() => controller.abort(), 60_000);
349
+ try {
350
+ const response = await fetch(`${provider.baseUrl}/audio/transcriptions`, {
351
+ method: 'POST',
352
+ headers: { Authorization: `Bearer ${provider.apiKey}` },
353
+ body: formData,
354
+ signal: controller.signal,
355
+ });
356
+ if (!response.ok) {
357
+ const errText = await response.text();
358
+ throw new Error(`Transcription API error (${response.status}): ${errText}`);
359
+ }
360
+ const data = await response.json();
361
+ return data.text;
362
+ }
363
+ finally {
364
+ clearTimeout(timer);
365
+ }
366
+ }
367
+ // ── Photo Handling ───────────────────────────────────────
368
+ /**
369
+ * Download a photo from Telegram and save it locally.
370
+ * Returns the local file path.
371
+ */
372
+ async downloadPhoto(fileId, messageId) {
373
+ const photoDir = path.join(this.stateDir, 'telegram-images');
374
+ fs.mkdirSync(photoDir, { recursive: true });
375
+ const filename = `photo-${Date.now()}-${messageId}.jpg`;
376
+ const filepath = path.join(photoDir, filename);
377
+ await this.downloadFile(fileId, filepath);
378
+ return filepath;
379
+ }
380
+ // ── Command Handling ─────────────────────────────────────
381
+ /**
382
+ * Process Telegram commands. Returns true if the message was a command.
383
+ */
384
+ async handleCommand(text, topicId, userId) {
385
+ const cmd = text.trim().toLowerCase();
386
+ // /sessions — list all sessions with claim status
387
+ if (cmd === '/sessions' || cmd.startsWith('/sessions ')) {
388
+ const filterUnclaimed = cmd.includes('unclaimed');
389
+ if (!this.onListSessions) {
390
+ await this.sendToTopic(topicId, 'Session listing not available.').catch(() => { });
391
+ return true;
392
+ }
393
+ const sessions = this.onListSessions();
394
+ if (sessions.length === 0) {
395
+ await this.sendToTopic(topicId, 'No sessions running.').catch(() => { });
396
+ return true;
397
+ }
398
+ const lines = [];
399
+ for (const s of sessions) {
400
+ const linkedTopic = this.getTopicForSession(s.tmuxSession);
401
+ const claimed = linkedTopic !== null;
402
+ if (filterUnclaimed && claimed)
403
+ continue;
404
+ const status = s.alive ? '\u2705' : '\u274c';
405
+ const claimTag = claimed ? ` (topic ${linkedTopic})` : ' \u{1f7e1} unclaimed';
406
+ lines.push(`${status} ${s.name}${claimTag}`);
407
+ }
408
+ if (lines.length === 0) {
409
+ await this.sendToTopic(topicId, filterUnclaimed ? 'No unclaimed sessions.' : 'No sessions.').catch(() => { });
410
+ }
411
+ else {
412
+ await this.sendToTopic(topicId, lines.join('\n')).catch(() => { });
413
+ }
414
+ return true;
415
+ }
416
+ // /claim <session> — claim a session into this topic
417
+ if (cmd.startsWith('/claim ')) {
418
+ const sessionName = text.trim().slice(7).trim();
419
+ if (!sessionName) {
420
+ await this.sendToTopic(topicId, 'Usage: /claim <session-name>').catch(() => { });
421
+ return true;
422
+ }
423
+ // Check if already claimed
424
+ const existingSession = this.getSessionForTopic(topicId);
425
+ if (existingSession) {
426
+ await this.sendToTopic(topicId, `This topic is already linked to "${existingSession}". Use /unlink first.`).catch(() => { });
427
+ return true;
428
+ }
429
+ this.registerTopicSession(topicId, sessionName);
430
+ await this.sendToTopic(topicId, `Claimed session "${sessionName}" into this topic.`).catch(() => { });
431
+ return true;
432
+ }
433
+ // /link <session> — alias for /claim
434
+ if (cmd.startsWith('/link ')) {
435
+ const sessionName = text.trim().slice(6).trim();
436
+ if (!sessionName) {
437
+ await this.sendToTopic(topicId, 'Usage: /link <session-name>').catch(() => { });
438
+ return true;
439
+ }
440
+ const existingSession = this.getSessionForTopic(topicId);
441
+ if (existingSession) {
442
+ await this.sendToTopic(topicId, `This topic is already linked to "${existingSession}". Use /unlink first.`).catch(() => { });
443
+ return true;
444
+ }
445
+ this.registerTopicSession(topicId, sessionName);
446
+ await this.sendToTopic(topicId, `Linked session "${sessionName}" to this topic.`).catch(() => { });
447
+ return true;
448
+ }
449
+ // /unlink — unlink session from this topic
450
+ if (cmd === '/unlink') {
451
+ const sessionName = this.getSessionForTopic(topicId);
452
+ if (!sessionName) {
453
+ await this.sendToTopic(topicId, 'No session linked to this topic.').catch(() => { });
454
+ return true;
455
+ }
456
+ this.unregisterTopic(topicId);
457
+ await this.sendToTopic(topicId, `Unlinked session "${sessionName}" from this topic.`).catch(() => { });
458
+ return true;
459
+ }
460
+ // /interrupt — send Escape to unstick a stalled session
461
+ if (cmd === '/interrupt') {
462
+ const sessionName = this.getSessionForTopic(topicId);
463
+ if (!sessionName) {
464
+ await this.sendToTopic(topicId, 'No session linked to this topic.').catch(() => { });
465
+ return true;
466
+ }
467
+ if (!this.onInterruptSession) {
468
+ await this.sendToTopic(topicId, 'Interrupt not available (no handler registered).').catch(() => { });
469
+ return true;
470
+ }
471
+ try {
472
+ const success = await this.onInterruptSession(sessionName);
473
+ if (success) {
474
+ await this.sendToTopic(topicId, `Sent Escape to "${sessionName}" \u2014 it should resume processing.`).catch(() => { });
475
+ }
476
+ else {
477
+ await this.sendToTopic(topicId, `Failed to interrupt "${sessionName}" \u2014 session may not exist.`).catch(() => { });
478
+ }
479
+ }
480
+ catch (err) {
481
+ await this.sendToTopic(topicId, `Interrupt error: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
482
+ }
483
+ return true;
484
+ }
485
+ // /restart — kill and respawn the session for this topic
486
+ if (cmd === '/restart') {
487
+ const sessionName = this.getSessionForTopic(topicId);
488
+ if (!sessionName) {
489
+ await this.sendToTopic(topicId, 'No session linked to this topic.').catch(() => { });
490
+ return true;
491
+ }
492
+ if (!this.onRestartSession) {
493
+ await this.sendToTopic(topicId, 'Restart not available (no handler registered).').catch(() => { });
494
+ return true;
495
+ }
496
+ await this.sendToTopic(topicId, `Restarting "${sessionName}"...`).catch(() => { });
497
+ try {
498
+ await this.onRestartSession(sessionName, topicId);
499
+ await this.sendToTopic(topicId, 'Session restarted.').catch(() => { });
500
+ }
501
+ catch (err) {
502
+ await this.sendToTopic(topicId, `Restart failed: ${err instanceof Error ? err.message : String(err)}`).catch(() => { });
503
+ }
504
+ return true;
505
+ }
506
+ // /status — show Telegram adapter status
507
+ if (cmd === '/status') {
508
+ const s = this.getStatus();
509
+ const lines = [
510
+ `Telegram adapter: ${s.started ? '\u2705 running' : '\u274c stopped'}`,
511
+ `Uptime: ${s.uptime ? Math.round(s.uptime / 60000) + 'm' : 'n/a'}`,
512
+ `Topic mappings: ${s.topicMappings}`,
513
+ `Pending stall alerts: ${s.pendingStalls}`,
514
+ ];
515
+ await this.sendToTopic(topicId, lines.join('\n')).catch(() => { });
516
+ return true;
517
+ }
518
+ return false;
519
+ }
156
520
  // ── Message Log ────────────────────────────────────────────
157
521
  /**
158
522
  * Get recent messages for a topic (for thread history on respawn).
@@ -191,7 +555,7 @@ export class TelegramAdapter {
191
555
  }
192
556
  /** Keep only the last 75,000 lines when log exceeds 100,000 lines.
193
557
  * High limits because message history is core agent memory.
194
- * At ~200 bytes/line average, 100k lines 20MB — fine for a dedicated machine. */
558
+ * At ~200 bytes/line average, 100k lines ~ 20MB — fine for a dedicated machine. */
195
559
  maybeRotateLog() {
196
560
  try {
197
561
  const stat = fs.statSync(this.messageLogPath);
@@ -214,7 +578,7 @@ export class TelegramAdapter {
214
578
  catch { /* ignore */ }
215
579
  throw rotateErr;
216
580
  }
217
- console.log(`[telegram] Rotated message log: ${lines.length} ${kept.length} lines`);
581
+ console.log(`[telegram] Rotated message log: ${lines.length} -> ${kept.length} lines`);
218
582
  }
219
583
  }
220
584
  catch {
@@ -304,61 +668,9 @@ export class TelegramAdapter {
304
668
  return;
305
669
  try {
306
670
  const updates = await this.getUpdates();
671
+ this.consecutivePollErrors = 0; // Reset on success
307
672
  for (const update of updates) {
308
- if (update.message?.text) {
309
- const msg = update.message;
310
- const text = msg.text;
311
- // Use message_thread_id if present; fall back to 1 (General topic) for forum groups
312
- const numericTopicId = msg.message_thread_id ?? 1;
313
- const topicId = numericTopicId.toString();
314
- // Auto-capture topic name from reply_to_message
315
- if (msg.reply_to_message?.forum_topic_created?.name) {
316
- if (!this.topicToName.has(numericTopicId)) {
317
- this.topicToName.set(numericTopicId, msg.reply_to_message.forum_topic_created.name);
318
- this.saveRegistry();
319
- }
320
- }
321
- const message = {
322
- id: `tg-${msg.message_id}`,
323
- userId: msg.from.id.toString(),
324
- content: text,
325
- channel: { type: 'telegram', identifier: topicId },
326
- receivedAt: new Date(msg.date * 1000).toISOString(),
327
- metadata: {
328
- telegramUserId: msg.from.id,
329
- username: msg.from.username,
330
- firstName: msg.from.first_name,
331
- messageThreadId: numericTopicId,
332
- },
333
- };
334
- // Log the message
335
- this.appendToLog({
336
- messageId: msg.message_id,
337
- topicId: numericTopicId,
338
- text,
339
- fromUser: true,
340
- timestamp: new Date(msg.date * 1000).toISOString(),
341
- sessionName: this.topicToSession.get(numericTopicId) ?? null,
342
- });
343
- // Fire topic message callback (always fires — General topic falls back to ID 1)
344
- if (this.onTopicMessage) {
345
- try {
346
- this.onTopicMessage(message);
347
- }
348
- catch (err) {
349
- console.error(`[telegram] Topic message handler error: ${err}`);
350
- }
351
- }
352
- // Fire general handler
353
- if (this.handler) {
354
- try {
355
- await this.handler(message);
356
- }
357
- catch (err) {
358
- console.error(`[telegram] Handler error: ${err}`);
359
- }
360
- }
361
- }
673
+ await this.processUpdate(update);
362
674
  this.lastUpdateId = Math.max(this.lastUpdateId, update.update_id);
363
675
  }
364
676
  // Persist offset so restarts don't re-process old messages
@@ -367,12 +679,251 @@ export class TelegramAdapter {
367
679
  }
368
680
  }
369
681
  catch (err) {
370
- console.error(`[telegram] Poll error: ${err}`);
682
+ this.consecutivePollErrors++;
683
+ const errMsg = err instanceof Error ? err.message : String(err);
684
+ // Check for fatal errors that require restart
685
+ if (errMsg.includes('401') || errMsg.includes('Unauthorized')) {
686
+ console.error(`[telegram] FATAL: Bot token is invalid. Stopping polling.`);
687
+ this.polling = false;
688
+ return;
689
+ }
690
+ // Exponential backoff on consecutive errors
691
+ if (this.consecutivePollErrors > 1) {
692
+ const backoffMs = Math.min(1000 * Math.pow(2, this.consecutivePollErrors - 1), 60_000);
693
+ console.error(`[telegram] Poll error (attempt ${this.consecutivePollErrors}), backing off ${backoffMs}ms: ${errMsg}`);
694
+ await new Promise(r => setTimeout(r, backoffMs));
695
+ }
696
+ else {
697
+ console.error(`[telegram] Poll error: ${errMsg}`);
698
+ }
371
699
  }
372
700
  // Schedule next poll
373
701
  const interval = this.config.pollIntervalMs ?? 2000;
374
702
  this.pollTimeout = setTimeout(() => this.poll(), interval);
375
703
  }
704
+ /**
705
+ * Process a single Telegram update (text, voice, or photo).
706
+ */
707
+ async processUpdate(update) {
708
+ const msg = update.message;
709
+ if (!msg)
710
+ return;
711
+ // Auth gating — reject messages from unauthorized users
712
+ if (!this.isAuthorized(msg.from.id)) {
713
+ console.log(`[telegram] Ignoring message from unauthorized user ${msg.from.id} (${msg.from.username ?? msg.from.first_name})`);
714
+ return;
715
+ }
716
+ const numericTopicId = msg.message_thread_id ?? 1;
717
+ const topicId = numericTopicId.toString();
718
+ // Auto-capture topic name from reply_to_message
719
+ if (msg.reply_to_message?.forum_topic_created?.name) {
720
+ if (!this.topicToName.has(numericTopicId)) {
721
+ this.topicToName.set(numericTopicId, msg.reply_to_message.forum_topic_created.name);
722
+ this.saveRegistry();
723
+ }
724
+ }
725
+ // Handle voice messages
726
+ if (msg.voice) {
727
+ await this.handleVoiceMessage(msg, numericTopicId);
728
+ return;
729
+ }
730
+ // Handle photo messages
731
+ if (msg.photo && msg.photo.length > 0) {
732
+ await this.handlePhotoMessage(msg, numericTopicId);
733
+ return;
734
+ }
735
+ // Handle text messages
736
+ if (!msg.text)
737
+ return;
738
+ const text = msg.text;
739
+ // Check for commands first
740
+ if (text.startsWith('/')) {
741
+ const handled = await this.handleCommand(text, numericTopicId, msg.from.id);
742
+ if (handled)
743
+ return;
744
+ }
745
+ const message = {
746
+ id: `tg-${msg.message_id}`,
747
+ userId: msg.from.id.toString(),
748
+ content: text,
749
+ channel: { type: 'telegram', identifier: topicId },
750
+ receivedAt: new Date(msg.date * 1000).toISOString(),
751
+ metadata: {
752
+ telegramUserId: msg.from.id,
753
+ username: msg.from.username,
754
+ firstName: msg.from.first_name,
755
+ messageThreadId: numericTopicId,
756
+ },
757
+ };
758
+ // Log the message
759
+ this.appendToLog({
760
+ messageId: msg.message_id,
761
+ topicId: numericTopicId,
762
+ text,
763
+ fromUser: true,
764
+ timestamp: new Date(msg.date * 1000).toISOString(),
765
+ sessionName: this.topicToSession.get(numericTopicId) ?? null,
766
+ });
767
+ // Fire topic message callback (always fires — General topic falls back to ID 1)
768
+ if (this.onTopicMessage) {
769
+ try {
770
+ this.onTopicMessage(message);
771
+ }
772
+ catch (err) {
773
+ console.error(`[telegram] Topic message handler error: ${err}`);
774
+ }
775
+ }
776
+ // Fire general handler
777
+ if (this.handler) {
778
+ try {
779
+ await this.handler(message);
780
+ }
781
+ catch (err) {
782
+ console.error(`[telegram] Handler error: ${err}`);
783
+ }
784
+ }
785
+ }
786
+ /**
787
+ * Handle an incoming voice message: download, transcribe, route as text.
788
+ */
789
+ async handleVoiceMessage(msg, topicId) {
790
+ const voice = msg.voice;
791
+ // Download the voice file
792
+ const voiceDir = path.join(this.stateDir, 'telegram-voice');
793
+ fs.mkdirSync(voiceDir, { recursive: true });
794
+ const filename = `voice-${Date.now()}-${msg.message_id}.ogg`;
795
+ const filepath = path.join(voiceDir, filename);
796
+ try {
797
+ await this.downloadFile(voice.file_id, filepath);
798
+ }
799
+ catch (err) {
800
+ console.error(`[telegram] Failed to download voice: ${err}`);
801
+ await this.sendToTopic(topicId, `(Voice message received but download failed)`).catch(() => { });
802
+ return;
803
+ }
804
+ // Transcribe
805
+ try {
806
+ const transcript = await this.transcribeVoice(filepath);
807
+ console.log(`[telegram] Transcribed voice (${voice.duration}s): "${transcript.slice(0, 80)}"`);
808
+ // Create a message with the transcription
809
+ const message = {
810
+ id: `tg-${msg.message_id}`,
811
+ userId: msg.from.id.toString(),
812
+ content: `[voice] ${transcript}`,
813
+ channel: { type: 'telegram', identifier: topicId.toString() },
814
+ receivedAt: new Date(msg.date * 1000).toISOString(),
815
+ metadata: {
816
+ telegramUserId: msg.from.id,
817
+ username: msg.from.username,
818
+ firstName: msg.from.first_name,
819
+ messageThreadId: topicId,
820
+ voiceFile: filepath,
821
+ voiceDuration: voice.duration,
822
+ },
823
+ };
824
+ // Log it
825
+ this.appendToLog({
826
+ messageId: msg.message_id,
827
+ topicId,
828
+ text: `[voice] ${transcript}`,
829
+ fromUser: true,
830
+ timestamp: new Date(msg.date * 1000).toISOString(),
831
+ sessionName: this.topicToSession.get(topicId) ?? null,
832
+ });
833
+ // Fire callbacks
834
+ if (this.onTopicMessage) {
835
+ try {
836
+ this.onTopicMessage(message);
837
+ }
838
+ catch (err) {
839
+ console.error(`[telegram] Topic message handler error: ${err}`);
840
+ }
841
+ }
842
+ if (this.handler) {
843
+ try {
844
+ await this.handler(message);
845
+ }
846
+ catch (err) {
847
+ console.error(`[telegram] Handler error: ${err}`);
848
+ }
849
+ }
850
+ }
851
+ catch (err) {
852
+ const errMsg = err instanceof Error ? err.message : String(err);
853
+ const isNotConfigured = errMsg.includes('No voice transcription provider configured');
854
+ const replyText = isNotConfigured
855
+ ? '\ud83c\udfa4 Voice transcription is not configured. To enable it, set GROQ_API_KEY or OPENAI_API_KEY in your environment.'
856
+ : `(Voice message received but transcription failed: ${errMsg})`;
857
+ await this.sendToTopic(topicId, replyText).catch(() => { });
858
+ }
859
+ finally {
860
+ // Clean up voice file after processing
861
+ try {
862
+ fs.unlinkSync(filepath);
863
+ }
864
+ catch { /* ignore */ }
865
+ }
866
+ }
867
+ /**
868
+ * Handle an incoming photo message: download, save, route with path.
869
+ */
870
+ async handlePhotoMessage(msg, topicId) {
871
+ const photos = msg.photo;
872
+ // Get highest resolution (last in array)
873
+ const photo = photos[photos.length - 1];
874
+ const caption = msg.caption || '';
875
+ try {
876
+ const filepath = await this.downloadPhoto(photo.file_id, msg.message_id);
877
+ console.log(`[telegram] Downloaded photo: ${filepath}`);
878
+ const content = caption
879
+ ? `[image:${filepath}] ${caption}`
880
+ : `[image:${filepath}]`;
881
+ const message = {
882
+ id: `tg-${msg.message_id}`,
883
+ userId: msg.from.id.toString(),
884
+ content,
885
+ channel: { type: 'telegram', identifier: topicId.toString() },
886
+ receivedAt: new Date(msg.date * 1000).toISOString(),
887
+ metadata: {
888
+ telegramUserId: msg.from.id,
889
+ username: msg.from.username,
890
+ firstName: msg.from.first_name,
891
+ messageThreadId: topicId,
892
+ photoPath: filepath,
893
+ },
894
+ };
895
+ // Log it
896
+ this.appendToLog({
897
+ messageId: msg.message_id,
898
+ topicId,
899
+ text: content,
900
+ fromUser: true,
901
+ timestamp: new Date(msg.date * 1000).toISOString(),
902
+ sessionName: this.topicToSession.get(topicId) ?? null,
903
+ });
904
+ // Fire callbacks
905
+ if (this.onTopicMessage) {
906
+ try {
907
+ this.onTopicMessage(message);
908
+ }
909
+ catch (err) {
910
+ console.error(`[telegram] Topic message handler error: ${err}`);
911
+ }
912
+ }
913
+ if (this.handler) {
914
+ try {
915
+ await this.handler(message);
916
+ }
917
+ catch (err) {
918
+ console.error(`[telegram] Handler error: ${err}`);
919
+ }
920
+ }
921
+ }
922
+ catch (err) {
923
+ console.error(`[telegram] Failed to download photo: ${err}`);
924
+ await this.sendToTopic(topicId, `(Photo received but download failed: ${err instanceof Error ? err.message : String(err)})`).catch(() => { });
925
+ }
926
+ }
376
927
  async getUpdates() {
377
928
  const result = await this.apiCall('getUpdates', {
378
929
  offset: this.lastUpdateId + 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",