@inetafrica/open-claudia 1.20.0 → 2.0.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.
@@ -0,0 +1,296 @@
1
+ // TelegramAdapter — wraps node-telegram-bot-api so core never touches
2
+ // the Telegram SDK directly. Inbound: normalises text/voice/audio/photo/
3
+ // document/callback_query into envelopes. Outbound: send/edit/delete +
4
+ // keyboard normalisation.
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const https = require("https");
9
+ const TelegramBot = require("node-telegram-bot-api");
10
+ const { TEMP_DIR, FILES_DIR, CONFIG_DIR } = require("../../core/config");
11
+ const { canonicalForChannel } = require("../../core/identity");
12
+ const { portableToInlineKeyboard } = require("../types");
13
+
14
+ class TelegramAdapter {
15
+ constructor({ id = "telegram", token, ownerChatId, chatIds }) {
16
+ this.id = id;
17
+ this.type = "telegram";
18
+ this.token = token;
19
+ this.ownerChatId = ownerChatId;
20
+ this.chatIds = chatIds || [];
21
+ this.bot = new TelegramBot(token, {
22
+ polling: { autoStart: false, params: { timeout: 30 } },
23
+ });
24
+ this._listeners = { message: new Set(), action: new Set() };
25
+ this._reconnectTimer = null;
26
+ this._wireInbound();
27
+ }
28
+
29
+ _emit(event, envelope) {
30
+ for (const fn of this._listeners[event] || []) {
31
+ try { Promise.resolve(fn(envelope)).catch((e) => console.error(`telegram ${event} handler:`, e.message)); }
32
+ catch (e) { console.error(`telegram ${event} handler:`, e.message); }
33
+ }
34
+ }
35
+
36
+ on(event, fn) {
37
+ if (!this._listeners[event]) return () => {};
38
+ this._listeners[event].add(fn);
39
+ return () => this._listeners[event].delete(fn);
40
+ }
41
+
42
+ async start() {
43
+ await this.bot.startPolling();
44
+ }
45
+
46
+ async stop() {
47
+ try { await this.bot.stopPolling(); } catch (e) {}
48
+ }
49
+
50
+ _wireInbound() {
51
+ this.bot.on("polling_error", (err) => {
52
+ const msg = err.message || "";
53
+ console.error("Polling error:", msg);
54
+ if (msg.includes("409 Conflict")) {
55
+ console.error("Another instance is polling. Exiting.");
56
+ process.exit(1);
57
+ }
58
+ if (msg.includes("ETIMEDOUT") || msg.includes("ECONNRESET") || msg.includes("ENOTFOUND") || msg.includes("EFATAL")) {
59
+ if (this._reconnectTimer) return;
60
+ console.log("Network lost. Reconnecting in 10s...");
61
+ this._reconnectTimer = setTimeout(async () => {
62
+ this._reconnectTimer = null;
63
+ try {
64
+ await this.bot.stopPolling();
65
+ await new Promise((r) => setTimeout(r, 2000));
66
+ await this.bot.startPolling();
67
+ console.log("Reconnected.");
68
+ } catch (e) {
69
+ console.error("Reconnect failed:", e.message);
70
+ process.exit(1);
71
+ }
72
+ }, 10000);
73
+ }
74
+ });
75
+
76
+ this.bot.on("message", (msg) => {
77
+ if (!msg || !msg.text || msg.text.startsWith("/")) return;
78
+ // Skip media — those have dedicated handlers below.
79
+ if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
80
+ this._emit("message", this._envelopeText(msg));
81
+ });
82
+
83
+ // Telegram delivers slash commands via the same `message` event but with
84
+ // text starting with "/". We fan them out separately so the router can
85
+ // dispatch via the registry instead of relying on bot.onText regexes.
86
+ this.bot.on("message", (msg) => {
87
+ if (!msg || !msg.text || !msg.text.startsWith("/")) return;
88
+ this._emit("message", this._envelopeText(msg, { isCommand: true }));
89
+ });
90
+
91
+ this.bot.on("voice", (msg) => this._emit("message", this._envelopeMedia(msg, "voice", msg.voice)));
92
+ this.bot.on("audio", (msg) => this._emit("message", this._envelopeMedia(msg, "audio", msg.audio)));
93
+ this.bot.on("photo", (msg) => {
94
+ const photo = msg.photo[msg.photo.length - 1];
95
+ this._emit("message", this._envelopeMedia(msg, "photo", photo));
96
+ });
97
+ this.bot.on("document", (msg) => this._emit("message", this._envelopeMedia(msg, "document", msg.document)));
98
+
99
+ this.bot.on("callback_query", (q) => {
100
+ const chatId = String(q.message?.chat?.id || q.from?.id);
101
+ const userId = String(q.from?.id || chatId);
102
+ this._emit("action", {
103
+ adapter: this,
104
+ channelId: chatId,
105
+ canonicalUserId: canonicalForChannel("telegram", chatId),
106
+ userId,
107
+ type: "action",
108
+ messageId: q.message?.message_id,
109
+ from: { id: userId, name: [q.from?.first_name, q.from?.last_name].filter(Boolean).join(" "), username: q.from?.username },
110
+ action: {
111
+ buttonId: q.data,
112
+ payload: q.data,
113
+ sourceMessageId: q.message?.message_id,
114
+ callbackId: q.id,
115
+ },
116
+ raw: q,
117
+ });
118
+ });
119
+ }
120
+
121
+ _envelopeText(msg, extra = {}) {
122
+ const chatId = String(msg.chat.id);
123
+ const userId = String(msg.from?.id || chatId);
124
+ return {
125
+ adapter: this,
126
+ channelId: chatId,
127
+ canonicalUserId: canonicalForChannel("telegram", chatId),
128
+ userId,
129
+ type: extra.isCommand ? "command" : "text",
130
+ text: msg.text,
131
+ messageId: msg.message_id,
132
+ replyToId: msg.reply_to_message?.message_id,
133
+ reply: msg.reply_to_message ? {
134
+ text: msg.reply_to_message.text,
135
+ caption: msg.reply_to_message.caption,
136
+ document: msg.reply_to_message.document,
137
+ photo: msg.reply_to_message.photo,
138
+ from: msg.reply_to_message.from,
139
+ } : null,
140
+ from: { id: userId, name: [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" "), username: msg.from?.username },
141
+ raw: msg,
142
+ };
143
+ }
144
+
145
+ _envelopeMedia(msg, kind, media) {
146
+ const chatId = String(msg.chat.id);
147
+ const userId = String(msg.from?.id || chatId);
148
+ return {
149
+ adapter: this,
150
+ channelId: chatId,
151
+ canonicalUserId: canonicalForChannel("telegram", chatId),
152
+ userId,
153
+ type: kind,
154
+ messageId: msg.message_id,
155
+ replyToId: msg.reply_to_message?.message_id,
156
+ caption: msg.caption,
157
+ from: { id: userId, name: [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" "), username: msg.from?.username },
158
+ media: [{
159
+ type: kind,
160
+ fileId: media.file_id,
161
+ fileName: media.file_name,
162
+ mimeType: media.mime_type,
163
+ size: media.file_size,
164
+ }],
165
+ raw: msg,
166
+ };
167
+ }
168
+
169
+ _normalizeKeyboard(keyboard) {
170
+ if (!keyboard) return null;
171
+ if (keyboard.inline_keyboard) return keyboard;
172
+ if (keyboard.buttons) {
173
+ return { inline_keyboard: portableToInlineKeyboard(keyboard.buttons) };
174
+ }
175
+ return null;
176
+ }
177
+
178
+ async send(channelId, text, opts = {}) {
179
+ const o = {};
180
+ if (opts.parseMode) o.parse_mode = opts.parseMode;
181
+ const kb = this._normalizeKeyboard(opts.keyboard);
182
+ if (kb) o.reply_markup = kb;
183
+ if (opts.replyTo) o.reply_to_message_id = opts.replyTo;
184
+
185
+ for (let attempt = 0; attempt < 3; attempt++) {
186
+ try {
187
+ const msg = await this.bot.sendMessage(channelId, text, o);
188
+ return msg.message_id;
189
+ } catch (e) {
190
+ const errMsg = e.message || "";
191
+ if (o.reply_to_message_id && errMsg.includes("message to be replied not found")) {
192
+ delete o.reply_to_message_id;
193
+ continue;
194
+ }
195
+ const retryMatch = errMsg.match(/retry after (\d+)/i);
196
+ if (retryMatch) {
197
+ const waitSec = Math.min(parseInt(retryMatch[1], 10), 30);
198
+ console.error(`Send: rate limited, waiting ${waitSec}s`);
199
+ await new Promise((r) => setTimeout(r, waitSec * 1000));
200
+ continue;
201
+ }
202
+ if (opts.parseMode && o.parse_mode) {
203
+ delete o.parse_mode;
204
+ continue;
205
+ }
206
+ console.error("Send error:", errMsg);
207
+ return null;
208
+ }
209
+ }
210
+ console.error("Send: exhausted retries");
211
+ return null;
212
+ }
213
+
214
+ async edit(channelId, messageId, text, opts = {}) {
215
+ try {
216
+ const o = { chat_id: channelId, message_id: messageId };
217
+ const kb = this._normalizeKeyboard(opts.keyboard);
218
+ if (kb) o.reply_markup = kb;
219
+ await this.bot.editMessageText(text, o);
220
+ } catch (e) {
221
+ const errMsg = e.message || "";
222
+ if (errMsg.includes("retry after")) return;
223
+ if (errMsg.includes("message is not modified")) return;
224
+ if (!errMsg.includes("message to edit not found")) console.error("Edit error:", errMsg);
225
+ }
226
+ }
227
+
228
+ async delete(channelId, messageId) {
229
+ try { await this.bot.deleteMessage(channelId, messageId); } catch (e) { /* ignore */ }
230
+ }
231
+
232
+ async sendVoice(channelId, oggPath) {
233
+ try {
234
+ await this.bot.sendVoice(channelId, oggPath);
235
+ try { fs.unlinkSync(oggPath); } catch (e) {}
236
+ return true;
237
+ } catch (e) {
238
+ console.error("Send voice error:", e.message);
239
+ try { fs.unlinkSync(oggPath); } catch (e2) {}
240
+ return false;
241
+ }
242
+ }
243
+
244
+ async sendFile(channelId, filePath, caption) {
245
+ try {
246
+ await this.bot.sendDocument(channelId, filePath, caption ? { caption } : {});
247
+ return true;
248
+ } catch (e) {
249
+ console.error("Send file error:", e.message);
250
+ return false;
251
+ }
252
+ }
253
+
254
+ async typing(channelId) {
255
+ try { await this.bot.sendChatAction(channelId, "typing"); } catch (e) {}
256
+ }
257
+
258
+ async downloadMedia(media) {
259
+ if (!media || !media.fileId) return null;
260
+ const file = await this.bot.getFile(media.fileId);
261
+ const fileUrl = `https://api.telegram.org/file/bot${this.token}/${file.file_path}`;
262
+ const ext = media.fileName ? path.extname(media.fileName) || ".bin"
263
+ : media.type === "voice" ? ".ogg"
264
+ : media.type === "audio" ? path.extname(media.fileName || "") || ".ogg"
265
+ : media.type === "photo" ? ".jpg"
266
+ : ".bin";
267
+ const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
268
+ const localPath = media.type === "document" && media.fileName
269
+ ? path.join(baseDir, media.fileName)
270
+ : path.join(baseDir, `tg-${Date.now()}${ext}`);
271
+ await new Promise((resolve, reject) => {
272
+ const out = fs.createWriteStream(localPath);
273
+ https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
274
+ });
275
+ return localPath;
276
+ }
277
+
278
+ async registerCommands(commands) {
279
+ // Telegram uses BotFather menus via setMyCommands. Strip leading
280
+ // slashes and clip to its 32-char name limit.
281
+ try {
282
+ const list = (commands || [])
283
+ .filter((c) => c && c.name)
284
+ .map((c) => ({
285
+ command: String(c.name).replace(/^\//, "").slice(0, 32),
286
+ description: String(c.description || c.name).slice(0, 256),
287
+ }))
288
+ .slice(0, 100);
289
+ await this.bot.setMyCommands(list);
290
+ } catch (e) {
291
+ console.error("Telegram setMyCommands failed:", e.message);
292
+ }
293
+ }
294
+ }
295
+
296
+ module.exports = { TelegramAdapter };
@@ -0,0 +1,12 @@
1
+ // Telegram MarkdownV1 quirks: single-asterisk bold, single-underscore
2
+ // italic, backticks for code. Most callers send `parseMode: "Markdown"`
3
+ // strings already authored for Telegram, so this module is mostly a
4
+ // pass-through with a couple of helpers.
5
+
6
+ function stripMarkdown(text) {
7
+ return String(text || "")
8
+ .replace(/[*_`~]/g, "")
9
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
10
+ }
11
+
12
+ module.exports = { stripMarkdown };
@@ -0,0 +1,91 @@
1
+ // JSDoc-only contract for ChannelAdapter. No runtime base class — every
2
+ // channel implementation duck-types these methods. Core code never
3
+ // touches a transport SDK directly; it goes through the adapter so the
4
+ // same handlers run on Telegram, Kazee, and any future channel.
5
+ //
6
+ /**
7
+ * @typedef {Object} MediaRef
8
+ * @property {string} type // "voice" | "audio" | "photo" | "document"
9
+ * @property {string} fileId // adapter-specific
10
+ * @property {string} [fileName]
11
+ * @property {string} [mimeType]
12
+ * @property {number} [size]
13
+ *
14
+ * @typedef {Object} InboundEnvelope
15
+ * @property {ChannelAdapter} adapter
16
+ * @property {string} channelId // raw transport id (telegram chatId, kazee chatId)
17
+ * @property {string} canonicalUserId // "telegram:123" | "kazee:abc" | "<email>"
18
+ * @property {string} userId // raw transport user id
19
+ * @property {string} type // "text" | "voice" | "audio" | "photo" | "document" | "action"
20
+ * @property {string} [text] // command text or message body
21
+ * @property {string} [messageId] // outer-channel message id
22
+ * @property {string} [replyToId] // id of message being replied to
23
+ * @property {Object} [reply] // {text, document, photo} for reply context
24
+ * @property {Array<MediaRef>} [media]
25
+ * @property {Object} [from] // {id, name, username}
26
+ * @property {Object} [action] // for type:"action" — {buttonId, payload, sourceMessageId}
27
+ * @property {any} raw // original payload, adapter-specific
28
+ *
29
+ * @typedef {Object} PortableButton
30
+ * @property {string} id
31
+ * @property {string} label
32
+ * @property {string} [style]
33
+ * @property {any} [payload]
34
+ *
35
+ * @typedef {Object} SendOpts
36
+ * @property {string} [parseMode]
37
+ * @property {Object} [keyboard] // either {inline_keyboard:[[{text,callback_data}]]}
38
+ * // (telegram-native, accepted by both adapters)
39
+ * // or {buttons:[PortableButton]} (portable)
40
+ * @property {string|number} [replyTo]
41
+ *
42
+ * @typedef {Object} CommandSpec
43
+ * @property {string} name // no leading slash
44
+ * @property {string} description
45
+ * @property {string} [args] // human-readable usage tail, e.g. "<model>"
46
+ *
47
+ * Every adapter implements:
48
+ * id: string // "telegram" | "kazee:<sub>" etc.
49
+ * type: "telegram" | "kazee"
50
+ * start(): Promise<void>
51
+ * stop(): Promise<void>
52
+ * on(event, fn): unsubscribe // events: "message" | "action"
53
+ * send(channelId, text, opts): Promise<messageId|null>
54
+ * edit(channelId, messageId, text, opts): Promise<void>
55
+ * delete(channelId, messageId): Promise<void>
56
+ * sendVoice(channelId, oggPath): Promise<boolean>
57
+ * sendFile(channelId, filePath, caption?): Promise<boolean>
58
+ * typing(channelId): Promise<void>
59
+ * downloadMedia(mediaRef): Promise<localPath|null>
60
+ * registerCommands(commands: CommandSpec[]): Promise<void>
61
+ */
62
+
63
+ // Helpers shared by adapters for keyboard-shape conversion.
64
+
65
+ function inlineKeyboardToPortable(inlineKeyboard) {
66
+ const buttons = [];
67
+ for (const row of inlineKeyboard || []) {
68
+ for (const btn of row || []) {
69
+ const cb = btn.callback_data || "";
70
+ buttons.push({
71
+ id: cb || btn.text,
72
+ label: btn.text,
73
+ payload: cb || null,
74
+ });
75
+ }
76
+ }
77
+ return buttons;
78
+ }
79
+
80
+ function portableToInlineKeyboard(buttons) {
81
+ const rows = [];
82
+ for (const b of buttons || []) {
83
+ rows.push([{
84
+ text: b.label,
85
+ callback_data: typeof b.payload === "string" ? b.payload : b.id,
86
+ }]);
87
+ }
88
+ return rows;
89
+ }
90
+
91
+ module.exports = { inlineKeyboardToPortable, portableToInlineKeyboard };
package/core/access.js ADDED
@@ -0,0 +1,147 @@
1
+ // Authorization rules. Pre-multi-channel this was strictly Telegram
2
+ // chat ids in .env + auth.json; the same store still drives access on
3
+ // any channel because the "chatId" recorded there is opaque (Telegram
4
+ // numeric ids today; Kazee userIds tomorrow). Channel resolution lives
5
+ // in the inbound envelope's canonicalUserId, not here.
6
+
7
+ const fs = require("fs");
8
+ const { AUTH_FILE, CHAT_ID, CHAT_IDS, saveEnvKey } = require("./config");
9
+
10
+ function loadAuth() {
11
+ try {
12
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
13
+ return {
14
+ authorized: Array.isArray(auth.authorized) ? auth.authorized : [],
15
+ pending: Array.isArray(auth.pending) ? auth.pending : [],
16
+ };
17
+ } catch (e) {
18
+ return { authorized: [], pending: [] };
19
+ }
20
+ }
21
+
22
+ function saveAuth(auth) {
23
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
24
+ }
25
+
26
+ function isChatAuthorized(chatId) {
27
+ const id = String(chatId || "");
28
+ if (!id) return false;
29
+ if (CHAT_IDS.includes(id)) return true;
30
+ try {
31
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
32
+ return Array.isArray(auth.authorized) && auth.authorized.some((a) => String(a.chatId) === id);
33
+ } catch (e) {}
34
+ return false;
35
+ }
36
+
37
+ function isChatOwner(chatId) {
38
+ const id = String(chatId || "");
39
+ if (!id) return false;
40
+ if (id === String(CHAT_ID || "")) return true;
41
+ try {
42
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
43
+ const authorized = Array.isArray(auth.authorized) ? auth.authorized : [];
44
+ if (authorized.some((a) => String(a.chatId) === id && a.isOwner === true)) return true;
45
+ // No explicit owner flag → fall back to .env CHAT_IDS membership.
46
+ if (!authorized.some((a) => a.isOwner === true) && CHAT_IDS.includes(id)) return true;
47
+ } catch (e) {}
48
+ return false;
49
+ }
50
+
51
+ function authRequestLabel(request) {
52
+ return request.username ? `@${request.username}` : request.name || request.chatId;
53
+ }
54
+
55
+ function updateAuthorizedChatEnv(auth) {
56
+ const ids = new Set(CHAT_IDS);
57
+ for (const user of auth.authorized) {
58
+ if (user.chatId) ids.add(String(user.chatId));
59
+ }
60
+ saveEnvKey("TELEGRAM_CHAT_ID", [...ids].join(","));
61
+ }
62
+
63
+ function approveAuthRequest(chatId) {
64
+ const auth = loadAuth();
65
+ if (auth.authorized.some((a) => String(a.chatId) === chatId)) {
66
+ auth.pending = auth.pending.filter((p) => String(p.chatId) !== chatId);
67
+ saveAuth(auth);
68
+ updateAuthorizedChatEnv(auth);
69
+ return { status: "already_authorized" };
70
+ }
71
+ const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
72
+ if (idx < 0) return { status: "not_found" };
73
+ const approved = auth.pending.splice(idx, 1)[0];
74
+ auth.authorized.push({
75
+ chatId: approved.chatId,
76
+ name: approved.name,
77
+ username: approved.username,
78
+ isOwner: false,
79
+ authorizedAt: new Date().toISOString(),
80
+ });
81
+ saveAuth(auth);
82
+ updateAuthorizedChatEnv(auth);
83
+ return { status: "approved", request: approved };
84
+ }
85
+
86
+ function denyAuthRequest(chatId) {
87
+ const auth = loadAuth();
88
+ const idx = auth.pending.findIndex((p) => String(p.chatId) === chatId);
89
+ if (idx < 0) return { status: "not_found" };
90
+ const denied = auth.pending.splice(idx, 1)[0];
91
+ saveAuth(auth);
92
+ return { status: "denied", request: denied };
93
+ }
94
+
95
+ function recordPendingAuthRequest({ chatId, name, username }) {
96
+ const auth = loadAuth();
97
+ if (auth.pending.some((p) => String(p.chatId) === String(chatId))) return { status: "already_pending" };
98
+ auth.pending.push({
99
+ chatId: String(chatId),
100
+ name,
101
+ username,
102
+ requestedAt: new Date().toISOString(),
103
+ });
104
+ saveAuth(auth);
105
+ return { status: "queued" };
106
+ }
107
+
108
+ function hasOwner() {
109
+ if (CHAT_ID) return true;
110
+ try {
111
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
112
+ return Array.isArray(auth.authorized) && auth.authorized.some((a) => a.isOwner === true);
113
+ } catch (e) {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ function bootstrapOwner({ chatId, name, username }) {
119
+ const id = String(chatId);
120
+ const auth = loadAuth();
121
+ auth.pending = auth.pending.filter((p) => String(p.chatId) !== id);
122
+ auth.authorized = auth.authorized.filter((a) => String(a.chatId) !== id);
123
+ auth.authorized.push({
124
+ chatId: id,
125
+ name: name || "",
126
+ username: username || "",
127
+ isOwner: true,
128
+ authorizedAt: new Date().toISOString(),
129
+ });
130
+ saveAuth(auth);
131
+ if (!CHAT_IDS.includes(id)) CHAT_IDS.push(id);
132
+ saveEnvKey("TELEGRAM_CHAT_ID", CHAT_IDS.join(","));
133
+ }
134
+
135
+ module.exports = {
136
+ loadAuth,
137
+ saveAuth,
138
+ isChatAuthorized,
139
+ isChatOwner,
140
+ authRequestLabel,
141
+ approveAuthRequest,
142
+ denyAuthRequest,
143
+ recordPendingAuthRequest,
144
+ updateAuthorizedChatEnv,
145
+ hasOwner,
146
+ bootstrapOwner,
147
+ };