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.
package/dist/commands/server.js
CHANGED
|
@@ -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
|
-
//
|
|
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);
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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<
|
|
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<
|
|
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
|
|
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
|
-
*
|
|
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:
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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,
|