@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,375 @@
1
+ // KazeeAdapter — talks to chat-central via REST (commands, message
2
+ // edit/delete, file upload) and socket.io v2 events (inbound messages,
3
+ // button presses). Mirrors TelegramAdapter's contract so core code
4
+ // stays transport-agnostic.
5
+
6
+ const fs = require("fs");
7
+ const path = require("path");
8
+ const https = require("https");
9
+ const http = require("http");
10
+ const { URL } = require("url");
11
+ const { TEMP_DIR, FILES_DIR } = require("../../core/config");
12
+ const { canonicalForChannel } = require("../../core/identity");
13
+ const { inlineKeyboardToPortable } = require("../types");
14
+ const { createKazeeSocket } = require("./socket");
15
+
16
+ class KazeeAdapter {
17
+ constructor({ id = "kazee", url, token, ownerUserId, botUserId }) {
18
+ this.id = id;
19
+ this.type = "kazee";
20
+ // KAZEE_URL is the root host (socket.io lives at /). REST routes are
21
+ // mounted under /api on chat-central; we prepend that internally.
22
+ this.url = url.replace(/\/$/, "");
23
+ this.apiBase = this.url + "/api";
24
+ this.token = token;
25
+ this.ownerUserId = ownerUserId || "";
26
+ this.botUserId = botUserId || null;
27
+ this._listeners = { message: new Set(), action: new Set() };
28
+ this._socket = null;
29
+ }
30
+
31
+ on(event, fn) {
32
+ if (!this._listeners[event]) return () => {};
33
+ this._listeners[event].add(fn);
34
+ return () => this._listeners[event].delete(fn);
35
+ }
36
+
37
+ _emit(event, envelope) {
38
+ for (const fn of this._listeners[event] || []) {
39
+ try { Promise.resolve(fn(envelope)).catch((e) => console.error(`kazee ${event} handler:`, e.message)); }
40
+ catch (e) { console.error(`kazee ${event} handler:`, e.message); }
41
+ }
42
+ }
43
+
44
+ async start() {
45
+ if (this.botUserId) {
46
+ console.log(`Kazee bot user id: ${this.botUserId}`);
47
+ } else {
48
+ console.error("Kazee adapter started without botUserId — set KAZEE_BOT_USER_ID. Slash commands and self-echo filtering will not work.");
49
+ }
50
+ this._socket = createKazeeSocket({
51
+ url: this.url,
52
+ token: this.token,
53
+ userId: this.botUserId,
54
+ onConnect: (s) => {
55
+ console.log(`Kazee socket connected (${this.url})`);
56
+ if (this.botUserId) {
57
+ // Join the bot's personal room so the server can fan out
58
+ // message:action presses directly to us.
59
+ s.emit("user:join", { userId: this.botUserId });
60
+ }
61
+ },
62
+ onDisconnect: (reason) => console.log(`Kazee socket disconnected: ${reason}`),
63
+ onError: (err) => console.error("Kazee socket error:", err.message),
64
+ });
65
+ this._wireSocket(this._socket);
66
+ }
67
+
68
+ async stop() {
69
+ try { this._socket?.disconnect?.(); } catch (e) {}
70
+ this._socket = null;
71
+ }
72
+
73
+ _wireSocket(socket) {
74
+ if (process.env.KAZEE_DEBUG_EVENTS) {
75
+ socket.onAny((event, ...args) => {
76
+ try { console.log(`[kazee event] ${event}`, JSON.stringify(args).slice(0, 400)); }
77
+ catch (e) { console.log(`[kazee event] ${event} (unserializable)`); }
78
+ });
79
+ }
80
+ socket.on("message:new", (msg) => this._handleInboundMessage(msg));
81
+ socket.on("message:action", (payload) => {
82
+ const channelId = String(payload.chatId);
83
+ const userId = String(payload.userId);
84
+ this._emit("action", {
85
+ adapter: this,
86
+ channelId,
87
+ canonicalUserId: canonicalForChannel("kazee", userId),
88
+ userId,
89
+ type: "action",
90
+ messageId: payload.messageId,
91
+ action: {
92
+ buttonId: payload.buttonId,
93
+ payload: payload.buttonPayload ?? payload.clientPayload ?? null,
94
+ sourceMessageId: payload.messageId,
95
+ },
96
+ raw: payload,
97
+ });
98
+ });
99
+ }
100
+
101
+ _handleInboundMessage(msg) {
102
+ if (!msg) return;
103
+ const senderId = String(msg.sender?._id || msg.sender?.id || msg.senderId || "");
104
+ if (this.botUserId && senderId === String(this.botUserId)) return; // ignore our own echoes
105
+ const chatField = msg.chat;
106
+ const channelId = String(
107
+ msg.chatId
108
+ || (typeof chatField === "string" ? chatField : (chatField?._id || chatField?.id))
109
+ || ""
110
+ );
111
+ if (!channelId) {
112
+ console.error("Kazee message:new missing chat id, ignoring:", JSON.stringify(msg).slice(0, 200));
113
+ return;
114
+ }
115
+ const userId = senderId || channelId;
116
+ const baseFrom = {
117
+ id: userId,
118
+ name: msg.sender?.name || msg.sender?.displayName || "",
119
+ username: msg.sender?.username || "",
120
+ };
121
+
122
+ const text = (msg.content || "").toString();
123
+ const isCommand = text.trim().startsWith("/");
124
+ const type = isCommand ? "command" : (msg.type === "voice" || msg.type === "audio" || msg.type === "image" || msg.type === "file" ? msg.type : "text");
125
+ const envelope = {
126
+ adapter: this,
127
+ channelId,
128
+ canonicalUserId: canonicalForChannel("kazee", userId),
129
+ userId,
130
+ type: type === "image" ? "photo" : type === "file" ? "document" : type,
131
+ text,
132
+ messageId: msg._id || msg.id,
133
+ replyToId: msg.replyTo,
134
+ from: baseFrom,
135
+ raw: msg,
136
+ };
137
+
138
+ if (msg.attachments && msg.attachments.length) {
139
+ envelope.media = msg.attachments.map((a) => ({
140
+ type: a.type || (a.mimeType?.startsWith("image/") ? "photo" : "document"),
141
+ fileId: a.url || a._id,
142
+ fileName: a.name || a.filename,
143
+ mimeType: a.mimeType,
144
+ size: a.size,
145
+ }));
146
+ if (envelope.type === "text") envelope.type = envelope.media[0].type === "image" ? "photo" : envelope.media[0].type;
147
+ }
148
+
149
+ this._emit("message", envelope);
150
+ }
151
+
152
+ _normalizeKeyboard(keyboard) {
153
+ if (!keyboard) return null;
154
+ if (keyboard.buttons) return keyboard.buttons;
155
+ if (keyboard.inline_keyboard) return inlineKeyboardToPortable(keyboard.inline_keyboard);
156
+ return null;
157
+ }
158
+
159
+ async send(channelId, text, opts = {}) {
160
+ const buttons = this._normalizeKeyboard(opts.keyboard);
161
+ const body = {
162
+ chat_id: channelId,
163
+ sender: this.botUserId,
164
+ content: text,
165
+ type: buttons ? "interactive" : "text",
166
+ findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
167
+ };
168
+ if (opts.replyTo) body.replyTo = opts.replyTo;
169
+ if (buttons) body.interactive = { buttons };
170
+ try {
171
+ const res = await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, body);
172
+ return res?.message?._id || res?.messageId || res?._id || null;
173
+ } catch (e) {
174
+ console.error("Kazee send error:", e.message);
175
+ return null;
176
+ }
177
+ }
178
+
179
+ async edit(channelId, messageId, text, opts = {}) {
180
+ const buttons = this._normalizeKeyboard(opts.keyboard);
181
+ const body = { content: text };
182
+ if (buttons) {
183
+ body.type = "interactive";
184
+ body.interactive = { buttons };
185
+ }
186
+ try {
187
+ await this._request("PUT", `/message/${encodeURIComponent(messageId)}`, body);
188
+ } catch (e) {
189
+ const msg = e.message || "";
190
+ if (msg.includes("not found")) return;
191
+ console.error("Kazee edit error:", msg);
192
+ }
193
+ }
194
+
195
+ async delete(channelId, messageId) {
196
+ try { await this._request("DELETE", `/message/${encodeURIComponent(messageId)}`); }
197
+ catch (e) { /* ignore */ }
198
+ }
199
+
200
+ async sendVoice(channelId, oggPath) {
201
+ const ok = await this.sendFile(channelId, oggPath);
202
+ try { fs.unlinkSync(oggPath); } catch (e) {}
203
+ return ok;
204
+ }
205
+
206
+ async sendFile(channelId, filePath, caption) {
207
+ try {
208
+ const buffer = fs.readFileSync(filePath);
209
+ const fileName = path.basename(filePath);
210
+ const upload = await this._uploadMultipart("/upload", { file: { buffer, fileName } });
211
+ const url = upload?.url || upload?.fileUrl || upload?.location;
212
+ if (!url) {
213
+ console.error("Kazee upload returned no url");
214
+ return false;
215
+ }
216
+ const body = {
217
+ chat_id: channelId,
218
+ sender: this.botUserId,
219
+ content: caption || fileName,
220
+ type: this._kazeeFileType(fileName),
221
+ findMeCode: `oc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
222
+ media_url: url,
223
+ attachments: [{ url, name: fileName }],
224
+ };
225
+ await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, body);
226
+ return true;
227
+ } catch (e) {
228
+ console.error("Kazee sendFile error:", e.message);
229
+ return false;
230
+ }
231
+ }
232
+
233
+ _kazeeFileType(fileName) {
234
+ const ext = path.extname(fileName).toLowerCase();
235
+ if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext)) return "image";
236
+ if ([".mp4", ".mov", ".webm"].includes(ext)) return "video";
237
+ if ([".mp3", ".m4a", ".ogg", ".wav"].includes(ext)) return "audio";
238
+ return "file";
239
+ }
240
+
241
+ async typing(channelId) {
242
+ try { this._socket?.emit?.("typing:start", { chatId: channelId }); } catch (e) {}
243
+ }
244
+
245
+ async downloadMedia(media) {
246
+ if (!media || !media.fileId) return null;
247
+ const url = media.fileId; // Kazee passes the cloud URL through fileId.
248
+ const ext = path.extname(media.fileName || "") || (media.type === "voice" ? ".ogg" : media.type === "photo" ? ".jpg" : ".bin");
249
+ const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
250
+ const localPath = media.type === "document" && media.fileName
251
+ ? path.join(baseDir, media.fileName)
252
+ : path.join(baseDir, `kz-${Date.now()}${ext}`);
253
+ await this._downloadUrl(url, localPath);
254
+ return localPath;
255
+ }
256
+
257
+ async registerCommands(commands) {
258
+ if (!this.botUserId) {
259
+ console.error("Kazee registerCommands: no botUserId yet, skipping");
260
+ return;
261
+ }
262
+ const list = (commands || [])
263
+ .filter((c) => c && c.name)
264
+ .map((c) => ({
265
+ name: String(c.name).replace(/^\//, ""),
266
+ description: String(c.description || ""),
267
+ args: typeof c.args === "string" ? c.args : "",
268
+ }));
269
+ try {
270
+ await this._request("PUT", `/bot/${this.botUserId}/commands`, { commands: list });
271
+ } catch (e) {
272
+ console.error("Kazee registerCommands failed:", e.message);
273
+ }
274
+ }
275
+
276
+ // ── HTTP helpers ────────────────────────────────────────────────
277
+
278
+ _request(method, urlPath, body) {
279
+ return new Promise((resolve, reject) => {
280
+ const u = new URL(this.apiBase + urlPath);
281
+ const lib = u.protocol === "https:" ? https : http;
282
+ const data = body ? Buffer.from(JSON.stringify(body)) : null;
283
+ const headers = {
284
+ Authorization: `Bearer ${this.token}`,
285
+ Accept: "application/json",
286
+ };
287
+ if (data) {
288
+ headers["Content-Type"] = "application/json";
289
+ headers["Content-Length"] = data.length;
290
+ }
291
+ const req = lib.request({
292
+ method,
293
+ hostname: u.hostname,
294
+ port: u.port || (u.protocol === "https:" ? 443 : 80),
295
+ path: u.pathname + (u.search || ""),
296
+ headers,
297
+ }, (res) => {
298
+ let chunks = "";
299
+ res.on("data", (c) => { chunks += c; });
300
+ res.on("end", () => {
301
+ if (res.statusCode >= 200 && res.statusCode < 300) {
302
+ try { resolve(chunks ? JSON.parse(chunks) : {}); }
303
+ catch (e) { resolve({}); }
304
+ } else {
305
+ reject(new Error(`Kazee ${method} ${urlPath} → ${res.statusCode}: ${chunks.slice(0, 500)}`));
306
+ }
307
+ });
308
+ });
309
+ req.on("error", reject);
310
+ req.setTimeout(30000, () => req.destroy(new Error("Kazee request timeout")));
311
+ if (data) req.write(data);
312
+ req.end();
313
+ });
314
+ }
315
+
316
+ _uploadMultipart(urlPath, files) {
317
+ return new Promise((resolve, reject) => {
318
+ const u = new URL(this.apiBase + urlPath);
319
+ const lib = u.protocol === "https:" ? https : http;
320
+ const boundary = `----oc-${Date.now()}-${Math.random().toString(36).slice(2)}`;
321
+ const parts = [];
322
+ for (const [field, info] of Object.entries(files)) {
323
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${info.fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
324
+ parts.push(info.buffer);
325
+ parts.push(Buffer.from("\r\n"));
326
+ }
327
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
328
+ const body = Buffer.concat(parts);
329
+ const req = lib.request({
330
+ method: "POST",
331
+ hostname: u.hostname,
332
+ port: u.port || (u.protocol === "https:" ? 443 : 80),
333
+ path: u.pathname,
334
+ headers: {
335
+ Authorization: `Bearer ${this.token}`,
336
+ "Content-Type": `multipart/form-data; boundary=${boundary}`,
337
+ "Content-Length": body.length,
338
+ },
339
+ }, (res) => {
340
+ let chunks = "";
341
+ res.on("data", (c) => { chunks += c; });
342
+ res.on("end", () => {
343
+ if (res.statusCode >= 200 && res.statusCode < 300) {
344
+ try { resolve(chunks ? JSON.parse(chunks) : {}); }
345
+ catch (e) { resolve({}); }
346
+ } else {
347
+ reject(new Error(`Kazee upload → ${res.statusCode}: ${chunks.slice(0, 500)}`));
348
+ }
349
+ });
350
+ });
351
+ req.on("error", reject);
352
+ req.setTimeout(120000, () => req.destroy(new Error("Kazee upload timeout")));
353
+ req.write(body);
354
+ req.end();
355
+ });
356
+ }
357
+
358
+ _downloadUrl(url, localPath) {
359
+ return new Promise((resolve, reject) => {
360
+ const lib = url.startsWith("https:") ? https : http;
361
+ const out = fs.createWriteStream(localPath);
362
+ lib.get(url, (res) => {
363
+ if (res.statusCode && res.statusCode >= 400) {
364
+ out.destroy();
365
+ reject(new Error(`Download ${url} → ${res.statusCode}`));
366
+ return;
367
+ }
368
+ res.pipe(out);
369
+ out.on("finish", () => { out.close(); resolve(); });
370
+ }).on("error", reject);
371
+ });
372
+ }
373
+ }
374
+
375
+ module.exports = { KazeeAdapter };
@@ -0,0 +1,9 @@
1
+ // Kazee uses standard markdown end-to-end. Telegram-flavoured asterisks
2
+ // and underscores still render fine, so this is mostly a hook for future
3
+ // divergence (image embeds, code-block fences, etc.).
4
+
5
+ function normalize(text) {
6
+ return String(text || "");
7
+ }
8
+
9
+ module.exports = { normalize };
@@ -0,0 +1,28 @@
1
+ // Thin wrapper around socket.io-client. Keeps the v2 event names + auth
2
+ // header in one place so the adapter stays focused on envelope shape.
3
+
4
+ let io;
5
+ try { io = require("socket.io-client").io; }
6
+ catch (e) { io = null; }
7
+
8
+ function createKazeeSocket({ url, token, userId, onConnect, onDisconnect, onError }) {
9
+ if (!io) {
10
+ throw new Error("socket.io-client is not installed. Run `npm install` to install dependencies.");
11
+ }
12
+ const query = { version: "v2" };
13
+ if (userId) query.userId = userId;
14
+ const socket = io(url, {
15
+ transports: ["websocket"],
16
+ auth: { token },
17
+ query,
18
+ reconnection: true,
19
+ reconnectionDelay: 2000,
20
+ reconnectionDelayMax: 30000,
21
+ });
22
+ socket.on("connect", () => { if (onConnect) onConnect(socket); });
23
+ socket.on("disconnect", (reason) => { if (onDisconnect) onDisconnect(reason); });
24
+ socket.on("connect_error", (err) => { if (onError) onError(err); });
25
+ return socket;
26
+ }
27
+
28
+ module.exports = { createKazeeSocket };