@c4t4/heyamigo 0.10.0 → 0.10.2
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/README.md +40 -248
- package/config/access.example.json +12 -2
- package/config/config.example.json +16 -0
- package/config/memory-instructions.md +1 -1
- package/config/personalities/casual.md +1 -1
- package/config/personalities/professional.md +1 -1
- package/config/personalities/sharp.md +2 -2
- package/dist/ai/claude.js +1 -0
- package/dist/ai/codex.js +1 -0
- package/dist/ai/grok.js +310 -0
- package/dist/ai/provider.js +5 -5
- package/dist/ai/providers.js +2 -0
- package/dist/ai/sessions.js +5 -1
- package/dist/boot.js +15 -6
- package/dist/channels/index.js +2 -1
- package/dist/channels/runtime.js +1 -0
- package/dist/channels/telegram.js +393 -0
- package/dist/cli/index.js +1 -1
- package/dist/cli/setup.js +168 -70
- package/dist/cli/start.js +25 -4
- package/dist/config.js +41 -6
- package/dist/db/address.js +13 -0
- package/dist/db/identity-sync.js +8 -0
- package/dist/gateway/bootstrap.js +15 -22
- package/dist/gateway/commands.js +13 -15
- package/dist/gateway/incoming.js +107 -254
- package/dist/gateway/ingest.js +240 -0
- package/dist/gateway/outgoing.js +3 -5
- package/dist/gateway/triggers.js +7 -40
- package/dist/memory/digest.js +5 -5
- package/dist/queue/async-tasks.js +11 -4
- package/dist/queue/browser-worker.js +5 -3
- package/dist/queue/cron-dispatch.js +6 -7
- package/dist/queue/job-address.js +4 -0
- package/dist/queue/outbound-postsend.js +4 -7
- package/dist/queue/worker.js +11 -5
- package/dist/wa/whitelist.js +40 -5
- package/package.json +3 -2
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'fs';
|
|
2
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
3
|
+
import { basename, extname, resolve } from 'path';
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
import { actorKeyFromAddress, addressToChatKey, formatAddress, } from '../db/address.js';
|
|
6
|
+
import { logger } from '../logger.js';
|
|
7
|
+
import { PermanentChannelError, TransientChannelError, } from './adapter.js';
|
|
8
|
+
let running = false;
|
|
9
|
+
let loopPromise = null;
|
|
10
|
+
let botIdentity = null;
|
|
11
|
+
function botToken() {
|
|
12
|
+
const token = config.telegram.botToken?.trim();
|
|
13
|
+
if (!token) {
|
|
14
|
+
throw new PermanentChannelError('telegram.botToken is not configured');
|
|
15
|
+
}
|
|
16
|
+
return token;
|
|
17
|
+
}
|
|
18
|
+
function apiUrl(method) {
|
|
19
|
+
return `https://api.telegram.org/bot${botToken()}/${method}`;
|
|
20
|
+
}
|
|
21
|
+
function fileUrl(path) {
|
|
22
|
+
return `https://api.telegram.org/file/bot${botToken()}/${path}`;
|
|
23
|
+
}
|
|
24
|
+
async function parseTelegramResponse(res) {
|
|
25
|
+
let body;
|
|
26
|
+
try {
|
|
27
|
+
body = await res.json();
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
if (res.status >= 500 || res.status === 429) {
|
|
31
|
+
throw new TransientChannelError(`telegram HTTP ${res.status}`, err);
|
|
32
|
+
}
|
|
33
|
+
throw new PermanentChannelError(`telegram HTTP ${res.status}`, err);
|
|
34
|
+
}
|
|
35
|
+
if (!res.ok || !body.ok || body.result === undefined) {
|
|
36
|
+
const message = body.description || `telegram HTTP ${res.status}`;
|
|
37
|
+
if (res.status >= 500 || res.status === 429 || body.error_code === 429) {
|
|
38
|
+
throw new TransientChannelError(message);
|
|
39
|
+
}
|
|
40
|
+
throw new PermanentChannelError(message);
|
|
41
|
+
}
|
|
42
|
+
return body.result;
|
|
43
|
+
}
|
|
44
|
+
async function telegramJson(method, payload) {
|
|
45
|
+
const res = await fetch(apiUrl(method), {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'content-type': 'application/json' },
|
|
48
|
+
body: JSON.stringify(payload),
|
|
49
|
+
});
|
|
50
|
+
return parseTelegramResponse(res);
|
|
51
|
+
}
|
|
52
|
+
async function telegramForm(method, form) {
|
|
53
|
+
const res = await fetch(apiUrl(method), {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
body: form,
|
|
56
|
+
});
|
|
57
|
+
return parseTelegramResponse(res);
|
|
58
|
+
}
|
|
59
|
+
function classifyUnexpected(err) {
|
|
60
|
+
if (err instanceof TransientChannelError || err instanceof PermanentChannelError) {
|
|
61
|
+
return err;
|
|
62
|
+
}
|
|
63
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
64
|
+
const lower = message.toLowerCase();
|
|
65
|
+
if (lower.includes('fetch failed') ||
|
|
66
|
+
lower.includes('network') ||
|
|
67
|
+
lower.includes('timeout') ||
|
|
68
|
+
lower.includes('econnreset')) {
|
|
69
|
+
return new TransientChannelError(message, err);
|
|
70
|
+
}
|
|
71
|
+
return new PermanentChannelError(message, err);
|
|
72
|
+
}
|
|
73
|
+
function mimeFor(filePath, fallback) {
|
|
74
|
+
if (fallback)
|
|
75
|
+
return fallback;
|
|
76
|
+
const ext = extname(filePath).toLowerCase();
|
|
77
|
+
return MIME_MAP[ext] ?? 'application/octet-stream';
|
|
78
|
+
}
|
|
79
|
+
const MIME_MAP = {
|
|
80
|
+
'.png': 'image/png',
|
|
81
|
+
'.jpg': 'image/jpeg',
|
|
82
|
+
'.jpeg': 'image/jpeg',
|
|
83
|
+
'.gif': 'image/gif',
|
|
84
|
+
'.webp': 'image/webp',
|
|
85
|
+
'.mp4': 'video/mp4',
|
|
86
|
+
'.mov': 'video/quicktime',
|
|
87
|
+
'.mp3': 'audio/mpeg',
|
|
88
|
+
'.ogg': 'audio/ogg',
|
|
89
|
+
'.opus': 'audio/opus',
|
|
90
|
+
'.m4a': 'audio/mp4',
|
|
91
|
+
'.wav': 'audio/wav',
|
|
92
|
+
'.pdf': 'application/pdf',
|
|
93
|
+
'.txt': 'text/plain',
|
|
94
|
+
'.csv': 'text/csv',
|
|
95
|
+
'.zip': 'application/zip',
|
|
96
|
+
};
|
|
97
|
+
function requireFile(path) {
|
|
98
|
+
try {
|
|
99
|
+
const stat = statSync(path);
|
|
100
|
+
const buf = readFileSync(path);
|
|
101
|
+
return { buf, bytes: stat.size };
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
throw new PermanentChannelError(`media file unreadable: ${path} (${err.message})`, err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function appendReply(form, msg) {
|
|
108
|
+
if (msg.quoteMsgId)
|
|
109
|
+
form.append('reply_to_message_id', msg.quoteMsgId);
|
|
110
|
+
}
|
|
111
|
+
function appendMediaFile(form, field, msg) {
|
|
112
|
+
if (!msg.mediaPath) {
|
|
113
|
+
throw new PermanentChannelError(`${msg.kind} outbound missing mediaPath`);
|
|
114
|
+
}
|
|
115
|
+
const { buf } = requireFile(msg.mediaPath);
|
|
116
|
+
const blob = new Blob([buf], { type: mimeFor(msg.mediaPath, msg.mediaMime) });
|
|
117
|
+
form.append(field, blob, basename(msg.mediaPath));
|
|
118
|
+
}
|
|
119
|
+
async function sendMedia(method, field, chatId, msg) {
|
|
120
|
+
const form = new FormData();
|
|
121
|
+
form.append('chat_id', chatId);
|
|
122
|
+
appendMediaFile(form, field, msg);
|
|
123
|
+
if (msg.text && msg.kind !== 'audio')
|
|
124
|
+
form.append('caption', msg.text);
|
|
125
|
+
appendReply(form, msg);
|
|
126
|
+
const sent = await telegramForm(method, form);
|
|
127
|
+
return { msgId: String(sent.message_id) };
|
|
128
|
+
}
|
|
129
|
+
export const telegramAdapter = {
|
|
130
|
+
channel: 'tg',
|
|
131
|
+
async sendTyping(externalId, state) {
|
|
132
|
+
if (state === 'paused')
|
|
133
|
+
return;
|
|
134
|
+
try {
|
|
135
|
+
await telegramJson('sendChatAction', {
|
|
136
|
+
chat_id: externalId,
|
|
137
|
+
action: 'typing',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// typing is a UX hint; never block real work on it
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
async send(externalId, msg) {
|
|
145
|
+
try {
|
|
146
|
+
switch (msg.kind) {
|
|
147
|
+
case 'text': {
|
|
148
|
+
if (!msg.text)
|
|
149
|
+
throw new PermanentChannelError('text outbound has no body');
|
|
150
|
+
const sent = await telegramJson('sendMessage', {
|
|
151
|
+
chat_id: externalId,
|
|
152
|
+
text: msg.text,
|
|
153
|
+
reply_to_message_id: msg.quoteMsgId,
|
|
154
|
+
});
|
|
155
|
+
return { msgId: String(sent.message_id) };
|
|
156
|
+
}
|
|
157
|
+
case 'image':
|
|
158
|
+
return sendMedia('sendPhoto', 'photo', externalId, msg);
|
|
159
|
+
case 'video':
|
|
160
|
+
return sendMedia('sendVideo', 'video', externalId, msg);
|
|
161
|
+
case 'audio':
|
|
162
|
+
return sendMedia('sendAudio', 'audio', externalId, msg);
|
|
163
|
+
case 'document':
|
|
164
|
+
return sendMedia('sendDocument', 'document', externalId, msg);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
throw classifyUnexpected(err);
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
function delay(ms) {
|
|
173
|
+
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
|
174
|
+
}
|
|
175
|
+
async function ensureBotIdentity() {
|
|
176
|
+
if (botIdentity)
|
|
177
|
+
return botIdentity;
|
|
178
|
+
botIdentity = await telegramJson('getMe', {});
|
|
179
|
+
return botIdentity;
|
|
180
|
+
}
|
|
181
|
+
async function pollLoop(handler) {
|
|
182
|
+
const me = await ensureBotIdentity();
|
|
183
|
+
let offset = 0;
|
|
184
|
+
logger.info({ username: me.username }, 'telegram polling started');
|
|
185
|
+
while (running) {
|
|
186
|
+
try {
|
|
187
|
+
const updates = await telegramJson('getUpdates', {
|
|
188
|
+
offset,
|
|
189
|
+
timeout: 25,
|
|
190
|
+
allowed_updates: ['message'],
|
|
191
|
+
});
|
|
192
|
+
for (const update of updates) {
|
|
193
|
+
offset = update.update_id + 1;
|
|
194
|
+
if (!update.message)
|
|
195
|
+
continue;
|
|
196
|
+
const incoming = toIncomingMessage(update.message, me);
|
|
197
|
+
if (!incoming)
|
|
198
|
+
continue;
|
|
199
|
+
await handler(incoming);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
logger.error({ err }, 'telegram polling failed');
|
|
204
|
+
await delay(config.telegram.pollIntervalMs);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export const telegramRuntime = {
|
|
209
|
+
channel: 'tg',
|
|
210
|
+
async start(handler) {
|
|
211
|
+
if (!config.telegram.enabled)
|
|
212
|
+
return;
|
|
213
|
+
if (running) {
|
|
214
|
+
logger.warn('telegram runtime already started; ignoring');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
running = true;
|
|
218
|
+
loopPromise = pollLoop(handler).catch((err) => {
|
|
219
|
+
running = false;
|
|
220
|
+
logger.error({ err }, 'telegram polling stopped');
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
async stop() {
|
|
224
|
+
running = false;
|
|
225
|
+
await loopPromise?.catch(() => undefined);
|
|
226
|
+
loopPromise = null;
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
function nameForUser(user) {
|
|
230
|
+
if (!user)
|
|
231
|
+
return undefined;
|
|
232
|
+
const full = [user.first_name, user.last_name].filter(Boolean).join(' ').trim();
|
|
233
|
+
if (user.username && full)
|
|
234
|
+
return `${full} (@${user.username})`;
|
|
235
|
+
return full || (user.username ? `@${user.username}` : undefined);
|
|
236
|
+
}
|
|
237
|
+
function chatName(chat) {
|
|
238
|
+
if (chat.title)
|
|
239
|
+
return chat.title;
|
|
240
|
+
const full = [chat.first_name, chat.last_name].filter(Boolean).join(' ').trim();
|
|
241
|
+
if (chat.username && full)
|
|
242
|
+
return `${full} (@${chat.username})`;
|
|
243
|
+
return full || (chat.username ? `@${chat.username}` : undefined);
|
|
244
|
+
}
|
|
245
|
+
function addressForChat(chat) {
|
|
246
|
+
const scope = chat.type === 'private' ? 'dm' : 'group';
|
|
247
|
+
return formatAddress({
|
|
248
|
+
channel: 'tg',
|
|
249
|
+
scope,
|
|
250
|
+
externalId: String(chat.id),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function mediaRef(msg) {
|
|
254
|
+
const photo = msg.photo?.slice().sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0)).at(-1);
|
|
255
|
+
if (photo) {
|
|
256
|
+
return {
|
|
257
|
+
mediaType: 'image',
|
|
258
|
+
fileId: photo.file_id,
|
|
259
|
+
bytes: photo.file_size ?? null,
|
|
260
|
+
mime: 'image/jpeg',
|
|
261
|
+
fileName: `${msg.message_id}.jpg`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (msg.video)
|
|
265
|
+
return fileRef('video', msg.video, 'video/mp4');
|
|
266
|
+
if (msg.audio)
|
|
267
|
+
return fileRef('audio', msg.audio, msg.audio.mime_type ?? 'audio/mpeg');
|
|
268
|
+
if (msg.voice)
|
|
269
|
+
return fileRef('audio', msg.voice, msg.voice.mime_type ?? 'audio/ogg');
|
|
270
|
+
if (msg.document)
|
|
271
|
+
return fileRef('document', msg.document, msg.document.mime_type ?? 'application/octet-stream');
|
|
272
|
+
if (msg.sticker)
|
|
273
|
+
return fileRef('sticker', msg.sticker, msg.sticker.mime_type ?? 'image/webp');
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
function fileRef(mediaType, doc, fallbackMime) {
|
|
277
|
+
return {
|
|
278
|
+
mediaType,
|
|
279
|
+
fileId: doc.file_id,
|
|
280
|
+
bytes: doc.file_size ?? null,
|
|
281
|
+
mime: doc.mime_type ?? fallbackMime,
|
|
282
|
+
fileName: doc.file_name,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function toIncomingMessage(msg, me) {
|
|
286
|
+
if (msg.from?.id === me.id)
|
|
287
|
+
return null;
|
|
288
|
+
if (msg.chat.type === 'channel')
|
|
289
|
+
return null;
|
|
290
|
+
const address = addressForChat(msg.chat);
|
|
291
|
+
const actorAddress = msg.from
|
|
292
|
+
? formatAddress({ channel: 'tg', scope: 'dm', externalId: String(msg.from.id) })
|
|
293
|
+
: null;
|
|
294
|
+
const media = mediaRef(msg);
|
|
295
|
+
const text = msg.text ?? msg.caption ?? '';
|
|
296
|
+
const lowerText = text.toLowerCase();
|
|
297
|
+
const botMention = me.username
|
|
298
|
+
? lowerText.includes(`@${me.username.toLowerCase()}`)
|
|
299
|
+
: false;
|
|
300
|
+
return {
|
|
301
|
+
id: String(msg.message_id),
|
|
302
|
+
externalMsgId: `tg:${msg.chat.id}:${msg.message_id}`,
|
|
303
|
+
channel: 'tg',
|
|
304
|
+
address,
|
|
305
|
+
chatKey: addressToChatKey(address),
|
|
306
|
+
accessKey: address,
|
|
307
|
+
actorAddress,
|
|
308
|
+
senderKey: actorAddress ? actorKeyFromAddress(actorAddress) : 'tg_unknown',
|
|
309
|
+
senderLabel: nameForUser(msg.from),
|
|
310
|
+
timestamp: msg.date,
|
|
311
|
+
text,
|
|
312
|
+
fromMe: false,
|
|
313
|
+
isGroup: msg.chat.type !== 'private',
|
|
314
|
+
messageType: messageType(msg),
|
|
315
|
+
mediaType: media?.mediaType ?? null,
|
|
316
|
+
mediaBytes: media?.bytes ?? null,
|
|
317
|
+
downloadMedia: media ? () => downloadTelegramMedia(media, address, msg.message_id) : undefined,
|
|
318
|
+
quoteMsgId: String(msg.message_id),
|
|
319
|
+
triggerHints: {
|
|
320
|
+
mentionedBot: botMention,
|
|
321
|
+
replyToBot: msg.reply_to_message?.from?.id === me.id,
|
|
322
|
+
},
|
|
323
|
+
chat: {
|
|
324
|
+
platform: 'Telegram',
|
|
325
|
+
isGroup: msg.chat.type !== 'private',
|
|
326
|
+
chatName: chatName(msg.chat),
|
|
327
|
+
externalId: String(msg.chat.id),
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function messageType(msg) {
|
|
332
|
+
if (msg.text)
|
|
333
|
+
return 'text';
|
|
334
|
+
if (msg.photo)
|
|
335
|
+
return 'photo';
|
|
336
|
+
if (msg.video)
|
|
337
|
+
return 'video';
|
|
338
|
+
if (msg.audio)
|
|
339
|
+
return 'audio';
|
|
340
|
+
if (msg.voice)
|
|
341
|
+
return 'voice';
|
|
342
|
+
if (msg.document)
|
|
343
|
+
return 'document';
|
|
344
|
+
if (msg.sticker)
|
|
345
|
+
return 'sticker';
|
|
346
|
+
return 'unknown';
|
|
347
|
+
}
|
|
348
|
+
function extForMedia(media, filePath) {
|
|
349
|
+
const fromName = media.fileName && extname(media.fileName);
|
|
350
|
+
if (fromName)
|
|
351
|
+
return fromName;
|
|
352
|
+
const fromPath = filePath && extname(filePath);
|
|
353
|
+
if (fromPath)
|
|
354
|
+
return fromPath;
|
|
355
|
+
for (const [ext, mime] of Object.entries(MIME_MAP)) {
|
|
356
|
+
if (mime === media.mime)
|
|
357
|
+
return ext;
|
|
358
|
+
}
|
|
359
|
+
return '.bin';
|
|
360
|
+
}
|
|
361
|
+
async function downloadTelegramMedia(media, address, messageId) {
|
|
362
|
+
try {
|
|
363
|
+
const file = await telegramJson('getFile', {
|
|
364
|
+
file_id: media.fileId,
|
|
365
|
+
});
|
|
366
|
+
if (!file.file_path) {
|
|
367
|
+
logger.warn({ messageId }, 'telegram getFile returned no file_path');
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
const res = await fetch(fileUrl(file.file_path));
|
|
371
|
+
if (!res.ok) {
|
|
372
|
+
logger.warn({ status: res.status, messageId }, 'telegram media download failed');
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
376
|
+
const dir = resolve(process.cwd(), config.storage.mediaDir, addressToChatKey(address));
|
|
377
|
+
await mkdir(dir, { recursive: true });
|
|
378
|
+
const ext = extForMedia(media, file.file_path);
|
|
379
|
+
const filename = `${messageId}${ext}`;
|
|
380
|
+
const filePath = resolve(dir, filename);
|
|
381
|
+
await writeFile(filePath, buffer);
|
|
382
|
+
return {
|
|
383
|
+
mediaType: media.mediaType,
|
|
384
|
+
mediaPath: filePath,
|
|
385
|
+
mediaMime: media.mime,
|
|
386
|
+
bytes: buffer.length,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
logger.error({ err, messageId }, 'telegram media download failed');
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -8,7 +8,7 @@ const pkgVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version;
|
|
|
8
8
|
const program = new Command();
|
|
9
9
|
program
|
|
10
10
|
.name('heyamigo')
|
|
11
|
-
.description('WhatsApp AI
|
|
11
|
+
.description('WhatsApp and Telegram AI bot powered by Claude, Codex, or Grok')
|
|
12
12
|
.version(pkgVersion);
|
|
13
13
|
program
|
|
14
14
|
.command('setup')
|