daemora 1.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.
Files changed (115) hide show
  1. package/README.md +666 -0
  2. package/SOUL.md +104 -0
  3. package/config/hooks.json +14 -0
  4. package/config/mcp.json +145 -0
  5. package/package.json +86 -0
  6. package/skills/.gitkeep +0 -0
  7. package/skills/apple-notes.md +193 -0
  8. package/skills/apple-reminders.md +189 -0
  9. package/skills/camsnap.md +162 -0
  10. package/skills/coding.md +14 -0
  11. package/skills/documents.md +13 -0
  12. package/skills/email.md +13 -0
  13. package/skills/gif-search.md +196 -0
  14. package/skills/healthcheck.md +225 -0
  15. package/skills/image-gen.md +147 -0
  16. package/skills/model-usage.md +182 -0
  17. package/skills/obsidian.md +207 -0
  18. package/skills/pdf.md +211 -0
  19. package/skills/research.md +13 -0
  20. package/skills/skill-creator.md +142 -0
  21. package/skills/spotify.md +149 -0
  22. package/skills/summarize.md +230 -0
  23. package/skills/things.md +199 -0
  24. package/skills/tmux.md +204 -0
  25. package/skills/trello.md +183 -0
  26. package/skills/video-frames.md +202 -0
  27. package/skills/weather.md +127 -0
  28. package/src/a2a/A2AClient.js +136 -0
  29. package/src/a2a/A2AServer.js +316 -0
  30. package/src/a2a/AgentCard.js +79 -0
  31. package/src/agents/SubAgentManager.js +369 -0
  32. package/src/agents/Supervisor.js +192 -0
  33. package/src/channels/BaseChannel.js +104 -0
  34. package/src/channels/DiscordChannel.js +288 -0
  35. package/src/channels/EmailChannel.js +172 -0
  36. package/src/channels/GoogleChatChannel.js +316 -0
  37. package/src/channels/HttpChannel.js +26 -0
  38. package/src/channels/LineChannel.js +168 -0
  39. package/src/channels/SignalChannel.js +186 -0
  40. package/src/channels/SlackChannel.js +329 -0
  41. package/src/channels/TeamsChannel.js +272 -0
  42. package/src/channels/TelegramChannel.js +347 -0
  43. package/src/channels/WhatsAppChannel.js +219 -0
  44. package/src/channels/index.js +198 -0
  45. package/src/cli.js +1267 -0
  46. package/src/config/agentProfiles.js +120 -0
  47. package/src/config/channels.js +32 -0
  48. package/src/config/default.js +206 -0
  49. package/src/config/models.js +123 -0
  50. package/src/config/permissions.js +167 -0
  51. package/src/core/AgentLoop.js +446 -0
  52. package/src/core/Compaction.js +143 -0
  53. package/src/core/CostTracker.js +116 -0
  54. package/src/core/EventBus.js +46 -0
  55. package/src/core/Task.js +67 -0
  56. package/src/core/TaskQueue.js +206 -0
  57. package/src/core/TaskRunner.js +226 -0
  58. package/src/daemon/DaemonManager.js +301 -0
  59. package/src/hooks/HookRunner.js +230 -0
  60. package/src/index.js +482 -0
  61. package/src/mcp/MCPAgentRunner.js +112 -0
  62. package/src/mcp/MCPClient.js +186 -0
  63. package/src/mcp/MCPManager.js +412 -0
  64. package/src/models/ModelRouter.js +180 -0
  65. package/src/safety/AuditLog.js +135 -0
  66. package/src/safety/CircuitBreaker.js +126 -0
  67. package/src/safety/FilesystemGuard.js +169 -0
  68. package/src/safety/GitRollback.js +139 -0
  69. package/src/safety/HumanApproval.js +156 -0
  70. package/src/safety/InputSanitizer.js +72 -0
  71. package/src/safety/PermissionGuard.js +83 -0
  72. package/src/safety/Sandbox.js +70 -0
  73. package/src/safety/SecretScanner.js +100 -0
  74. package/src/safety/SecretVault.js +250 -0
  75. package/src/scheduler/Heartbeat.js +115 -0
  76. package/src/scheduler/Scheduler.js +228 -0
  77. package/src/services/models/outputSchema.js +15 -0
  78. package/src/services/openai.js +25 -0
  79. package/src/services/sessions.js +65 -0
  80. package/src/setup/theme.js +110 -0
  81. package/src/setup/wizard.js +788 -0
  82. package/src/skills/SkillLoader.js +168 -0
  83. package/src/storage/TaskStore.js +69 -0
  84. package/src/systemPrompt.js +526 -0
  85. package/src/tenants/TenantContext.js +19 -0
  86. package/src/tenants/TenantManager.js +379 -0
  87. package/src/tools/ToolRegistry.js +141 -0
  88. package/src/tools/applyPatch.js +144 -0
  89. package/src/tools/browserAutomation.js +223 -0
  90. package/src/tools/createDocument.js +265 -0
  91. package/src/tools/cronTool.js +105 -0
  92. package/src/tools/editFile.js +139 -0
  93. package/src/tools/executeCommand.js +123 -0
  94. package/src/tools/glob.js +67 -0
  95. package/src/tools/grep.js +121 -0
  96. package/src/tools/imageAnalysis.js +120 -0
  97. package/src/tools/index.js +173 -0
  98. package/src/tools/listDirectory.js +47 -0
  99. package/src/tools/manageAgents.js +47 -0
  100. package/src/tools/manageMCP.js +159 -0
  101. package/src/tools/memory.js +478 -0
  102. package/src/tools/messageChannel.js +45 -0
  103. package/src/tools/projectTracker.js +259 -0
  104. package/src/tools/readFile.js +52 -0
  105. package/src/tools/screenCapture.js +112 -0
  106. package/src/tools/searchContent.js +76 -0
  107. package/src/tools/searchFiles.js +75 -0
  108. package/src/tools/sendEmail.js +118 -0
  109. package/src/tools/sendFile.js +63 -0
  110. package/src/tools/textToSpeech.js +161 -0
  111. package/src/tools/transcribeAudio.js +82 -0
  112. package/src/tools/useMCP.js +29 -0
  113. package/src/tools/webFetch.js +150 -0
  114. package/src/tools/webSearch.js +134 -0
  115. package/src/tools/writeFile.js +26 -0
@@ -0,0 +1,347 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+ import { transcribeAudio } from "../tools/transcribeAudio.js";
4
+ import { writeFileSync, mkdirSync } from "node:fs";
5
+ import { join, extname } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ /**
9
+ * Telegram Channel — receives messages via Telegram Bot API (grammy).
10
+ *
11
+ * Handles:
12
+ * ✅ Text messages
13
+ * ✅ Voice messages → transcribed with Whisper → processed as text task
14
+ * ✅ Photos → downloaded to /tmp → agent can imageAnalysis the path
15
+ * ✅ Videos → downloaded to /tmp → included as context
16
+ * ✅ Documents/files → downloaded to /tmp → included as context
17
+ * ✅ Audio files → transcribed like voice messages
18
+ *
19
+ * Agent can send back:
20
+ * ✅ Text replies
21
+ * ✅ Photos (jpg/png/gif/webp)
22
+ * ✅ Videos (mp4/mov/avi)
23
+ * ✅ Documents (any other file)
24
+ *
25
+ * Config:
26
+ * token — Bot token from @BotFather
27
+ * allowlist — Optional array of chat IDs allowed to send tasks. Empty = open.
28
+ * model — Optional model override
29
+ */
30
+ export class TelegramChannel extends BaseChannel {
31
+ constructor(config) {
32
+ super("telegram", config);
33
+ this.bot = null;
34
+ }
35
+
36
+ async start() {
37
+ const { Bot, InputFile } = await import("grammy");
38
+ this._InputFile = InputFile;
39
+
40
+ if (!this.config.token) {
41
+ console.log(`[Channel:Telegram] Skipped — no TELEGRAM_BOT_TOKEN`);
42
+ return;
43
+ }
44
+
45
+ this.bot = new Bot(this.config.token);
46
+
47
+ console.log(`[Channel:Telegram] Connecting to Telegram API...`);
48
+ const me = await Promise.race([
49
+ this.bot.api.getMe(),
50
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Connection timed out after 10s")), 10000)),
51
+ ]);
52
+ console.log(`[Channel:Telegram] Verified bot: @${me.username}`);
53
+
54
+ // ── Text messages ────────────────────────────────────────────────────────
55
+ this.bot.on("message:text", async (ctx) => {
56
+ const chatId = ctx.chat.id.toString();
57
+ const text = ctx.message.text;
58
+ const userName = ctx.from?.first_name || "User";
59
+
60
+ if (!this.isAllowed(chatId)) {
61
+ await ctx.reply("You are not authorized to use this agent.");
62
+ return;
63
+ }
64
+
65
+ console.log(`[Channel:Telegram] Text from ${userName} (${chatId}): "${text.slice(0, 80)}"`);
66
+ await ctx.replyWithChatAction("typing");
67
+ await this.sendReaction({ chatId, messageId: ctx.message.message_id }, "⏳");
68
+ await this._processTask(ctx, chatId, text);
69
+ });
70
+
71
+ // ── Voice messages ───────────────────────────────────────────────────────
72
+ this.bot.on("message:voice", async (ctx) => {
73
+ const chatId = ctx.chat.id.toString();
74
+ const userName = ctx.from?.first_name || "User";
75
+
76
+ if (!this.isAllowed(chatId)) {
77
+ await ctx.reply("You are not authorized to use this agent.");
78
+ return;
79
+ }
80
+
81
+ console.log(`[Channel:Telegram] Voice from ${userName} (${chatId}) — transcribing...`);
82
+ await ctx.replyWithChatAction("typing");
83
+ await this.sendReaction({ chatId, messageId: ctx.message.message_id }, "⏳");
84
+
85
+ const audioPath = await this._downloadFile(ctx.message.voice.file_id, ".ogg");
86
+ if (!audioPath) {
87
+ await ctx.reply("Sorry, I couldn't download your voice message.");
88
+ return;
89
+ }
90
+
91
+ const transcript = await transcribeAudio(audioPath);
92
+ if (transcript.startsWith("Error:")) {
93
+ // Fall back: let user know STT isn't configured
94
+ await ctx.reply(`I received your voice message but couldn't transcribe it: ${transcript}\n\nPlease type your request instead.`);
95
+ return;
96
+ }
97
+
98
+ console.log(`[Channel:Telegram] Voice transcript: "${transcript.slice(0, 80)}"`);
99
+ await this._processTask(ctx, chatId, `[Voice message]: ${transcript}`);
100
+ });
101
+
102
+ // ── Audio files ──────────────────────────────────────────────────────────
103
+ this.bot.on("message:audio", async (ctx) => {
104
+ const chatId = ctx.chat.id.toString();
105
+ if (!this.isAllowed(chatId)) { await ctx.reply("Not authorized."); return; }
106
+
107
+ await ctx.replyWithChatAction("typing");
108
+ await this.sendReaction({ chatId, messageId: ctx.message.message_id }, "⏳");
109
+
110
+ const audio = ctx.message.audio;
111
+ const ext = extname(audio.file_name || ".mp3") || ".mp3";
112
+ const audioPath = await this._downloadFile(audio.file_id, ext);
113
+ if (!audioPath) { await ctx.reply("Couldn't download audio file."); return; }
114
+
115
+ const transcript = await transcribeAudio(audioPath);
116
+ const input = transcript.startsWith("Error:")
117
+ ? `[Audio file received: ${audioPath}]\n${transcript}`
118
+ : `[Audio transcript]: ${transcript}`;
119
+
120
+ await this._processTask(ctx, chatId, input);
121
+ });
122
+
123
+ // ── Photos ───────────────────────────────────────────────────────────────
124
+ this.bot.on("message:photo", async (ctx) => {
125
+ const chatId = ctx.chat.id.toString();
126
+ const userName = ctx.from?.first_name || "User";
127
+
128
+ if (!this.isAllowed(chatId)) {
129
+ await ctx.reply("You are not authorized to use this agent.");
130
+ return;
131
+ }
132
+
133
+ console.log(`[Channel:Telegram] Photo from ${userName} (${chatId})`);
134
+ await ctx.replyWithChatAction("typing");
135
+ await this.sendReaction({ chatId, messageId: ctx.message.message_id }, "⏳");
136
+
137
+ // Take the highest-resolution version (last in array)
138
+ const photos = ctx.message.photo;
139
+ const best = photos[photos.length - 1];
140
+ const imgPath = await this._downloadFile(best.file_id, ".jpg");
141
+
142
+ if (!imgPath) {
143
+ await ctx.reply("Sorry, I couldn't download your photo.");
144
+ return;
145
+ }
146
+
147
+ const caption = ctx.message.caption?.trim() || "";
148
+ const input = caption
149
+ ? `[Photo received: ${imgPath}]\nUser caption: ${caption}`
150
+ : `[Photo received: ${imgPath}]\nDescribe this image and respond to it.`;
151
+
152
+ await this._processTask(ctx, chatId, input);
153
+ });
154
+
155
+ // ── Videos ───────────────────────────────────────────────────────────────
156
+ this.bot.on("message:video", async (ctx) => {
157
+ const chatId = ctx.chat.id.toString();
158
+ if (!this.isAllowed(chatId)) { await ctx.reply("Not authorized."); return; }
159
+
160
+ await ctx.replyWithChatAction("upload_video");
161
+ await this.sendReaction({ chatId, messageId: ctx.message.message_id }, "⏳");
162
+
163
+ const videoPath = await this._downloadFile(ctx.message.video.file_id, ".mp4");
164
+ const caption = ctx.message.caption?.trim() || "";
165
+ const input = videoPath
166
+ ? `[Video received: ${videoPath}]\n${caption || "User sent a video."}`
167
+ : `[User sent a video but download failed]\n${caption || ""}`;
168
+
169
+ await this._processTask(ctx, chatId, input);
170
+ });
171
+
172
+ // ── Documents / files ────────────────────────────────────────────────────
173
+ this.bot.on("message:document", async (ctx) => {
174
+ const chatId = ctx.chat.id.toString();
175
+ if (!this.isAllowed(chatId)) { await ctx.reply("Not authorized."); return; }
176
+
177
+ await ctx.replyWithChatAction("typing");
178
+ await this.sendReaction({ chatId, messageId: ctx.message.message_id }, "⏳");
179
+
180
+ const doc = ctx.message.document;
181
+ const ext = extname(doc.file_name || "") || "";
182
+ const filePath = await this._downloadFile(doc.file_id, ext);
183
+ const caption = ctx.message.caption?.trim() || "";
184
+
185
+ const input = filePath
186
+ ? `[File received: ${filePath} (${doc.file_name || "document"}, ${_fmtSize(doc.file_size)})]\n${caption || "User sent a file."}`
187
+ : `[User sent a file "${doc.file_name}" but download failed]\n${caption || ""}`;
188
+
189
+ await this._processTask(ctx, chatId, input);
190
+ });
191
+
192
+ // Start long polling in background
193
+ this.bot.start().catch((err) => {
194
+ console.log(`[Channel:Telegram] Polling error: ${err.message}`);
195
+ this.running = false;
196
+ });
197
+
198
+ this.running = true;
199
+ console.log(`[Channel:Telegram] Started as @${me.username}`);
200
+ if (this.config.allowlist?.length) {
201
+ console.log(`[Channel:Telegram] Allowlist active — ${this.config.allowlist.length} authorized chat(s)`);
202
+ }
203
+ }
204
+
205
+ // ── Shared task processor ──────────────────────────────────────────────────
206
+ async _processTask(ctx, chatId, input) {
207
+ const messageId = ctx.message.message_id;
208
+ const userName = ctx.from?.first_name || "User";
209
+
210
+ const task = taskQueue.enqueue({
211
+ input,
212
+ channel: "telegram",
213
+ channelMeta: { chatId, userName, messageId, channel: "telegram" },
214
+ sessionId: this.getSessionId(chatId),
215
+ model: this.getModel(),
216
+ });
217
+
218
+ try {
219
+ const completedTask = await taskQueue.waitForCompletion(task.id);
220
+
221
+ // Task was absorbed into a concurrent agent session — response already sent
222
+ if (this.isTaskMerged(completedTask)) {
223
+ await this.sendReaction({ chatId, messageId }, "✅");
224
+ return;
225
+ }
226
+
227
+ const failed = completedTask.status === "failed";
228
+ const response = failed
229
+ ? `Sorry, I encountered an error: ${completedTask.error}`
230
+ : completedTask.result || "Done.";
231
+
232
+ await this.sendReaction({ chatId, messageId }, failed ? "❌" : "✅");
233
+
234
+ const chunks = splitMessage(response, 4096);
235
+ for (const chunk of chunks) {
236
+ await ctx.reply(chunk).catch(() => {});
237
+ }
238
+ } catch (error) {
239
+ console.error(`[Channel:Telegram] Error:`, error.message);
240
+ await this.sendReaction({ chatId, messageId }, "❌");
241
+ await ctx.reply("Sorry, something went wrong. Please try again.").catch(() => {});
242
+ }
243
+ }
244
+
245
+ // ── Download a Telegram file to /tmp ──────────────────────────────────────
246
+ async _downloadFile(fileId, extension) {
247
+ try {
248
+ const file = await this.bot.api.getFile(fileId);
249
+ const fileUrl = `https://api.telegram.org/file/bot${this.config.token}/${file.file_path}`;
250
+
251
+ const res = await fetch(fileUrl, { signal: AbortSignal.timeout(30000) });
252
+ if (!res.ok) return null;
253
+
254
+ const buffer = await res.arrayBuffer();
255
+ const tmpDir = join(tmpdir(), "daemora-tg");
256
+ mkdirSync(tmpDir, { recursive: true });
257
+ const filePath = join(tmpDir, `${fileId}${extension}`);
258
+ writeFileSync(filePath, Buffer.from(buffer));
259
+
260
+ return filePath;
261
+ } catch (err) {
262
+ console.log(`[Channel:Telegram] File download error: ${err.message}`);
263
+ return null;
264
+ }
265
+ }
266
+
267
+ async stop() {
268
+ if (this.bot) {
269
+ await this.bot.stop();
270
+ this.running = false;
271
+ console.log(`[Channel:Telegram] Stopped`);
272
+ }
273
+ }
274
+
275
+ async sendReply(channelMeta, text) {
276
+ if (!this.bot) return;
277
+ const chunks = splitMessage(text, 4096);
278
+ for (const chunk of chunks) {
279
+ await this.bot.api.sendMessage(channelMeta.chatId, chunk).catch(() => {});
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Send a local file to a Telegram chat.
285
+ * Auto-detects type: image → sendPhoto, video → sendVideo, other → sendDocument.
286
+ */
287
+ async sendFile(channelMeta, filePath, caption) {
288
+ if (!this.bot || !this._InputFile) return;
289
+ const chatId = channelMeta.chatId;
290
+ if (!chatId) return;
291
+
292
+ const ext = extname(filePath).toLowerCase();
293
+ const imgExts = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
294
+ const videoExts = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm"]);
295
+
296
+ const opts = caption ? { caption } : {};
297
+
298
+ try {
299
+ if (imgExts.has(ext)) {
300
+ await this.bot.api.sendPhoto(chatId, new this._InputFile(filePath), opts);
301
+ } else if (videoExts.has(ext)) {
302
+ await this.bot.api.sendVideo(chatId, new this._InputFile(filePath), opts);
303
+ } else {
304
+ await this.bot.api.sendDocument(chatId, new this._InputFile(filePath), opts);
305
+ }
306
+ } catch (err) {
307
+ console.log(`[Channel:Telegram] sendFile error: ${err.message}`);
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Set a native Telegram emoji reaction on a message.
313
+ * Uses setMessageReaction (Bot API 7.0+). Silent failure if not supported.
314
+ */
315
+ async sendReaction(channelMeta, emoji) {
316
+ if (!this.bot || !channelMeta.messageId) return;
317
+ try {
318
+ await this.bot.api.setMessageReaction(
319
+ channelMeta.chatId,
320
+ channelMeta.messageId,
321
+ [{ type: "emoji", emoji }]
322
+ );
323
+ } catch (_) {}
324
+ }
325
+ }
326
+
327
+ function splitMessage(text, maxLength) {
328
+ if (text.length <= maxLength) return [text];
329
+ const chunks = [];
330
+ let remaining = text;
331
+ while (remaining.length > 0) {
332
+ if (remaining.length <= maxLength) { chunks.push(remaining); break; }
333
+ let idx = remaining.lastIndexOf("\n", maxLength);
334
+ if (idx === -1 || idx < maxLength * 0.5) idx = remaining.lastIndexOf(" ", maxLength);
335
+ if (idx === -1) idx = maxLength;
336
+ chunks.push(remaining.slice(0, idx));
337
+ remaining = remaining.slice(idx).trimStart();
338
+ }
339
+ return chunks;
340
+ }
341
+
342
+ function _fmtSize(bytes) {
343
+ if (!bytes) return "unknown size";
344
+ if (bytes < 1024) return `${bytes} B`;
345
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
346
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
347
+ }
@@ -0,0 +1,219 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+ import { transcribeAudio } from "../tools/transcribeAudio.js";
4
+ import { writeFileSync, mkdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+
8
+ /**
9
+ * WhatsApp Channel — receives messages via Twilio webhook.
10
+ *
11
+ * Setup:
12
+ * 1. Create Twilio account + WhatsApp sandbox
13
+ * 2. Set webhook URL to: https://your-server/webhooks/whatsapp
14
+ * 3. Set env: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_WHATSAPP_FROM
15
+ *
16
+ * Config:
17
+ * accountSid — Twilio account SID
18
+ * authToken — Twilio auth token
19
+ * from — Your WhatsApp number (whatsapp:+14155238886)
20
+ * allowlist — Optional array of phone numbers (+1234567890) allowed to send tasks
21
+ * model — Optional model override
22
+ */
23
+ export class WhatsAppChannel extends BaseChannel {
24
+ constructor(config) {
25
+ super("whatsapp", config);
26
+ this.twilioClient = null;
27
+ }
28
+
29
+ async start() {
30
+ if (!this.config.accountSid || !this.config.authToken) {
31
+ console.log(`[Channel:WhatsApp] Skipped — no Twilio credentials`);
32
+ return;
33
+ }
34
+
35
+ const twilio = await import("twilio");
36
+ this.twilioClient = twilio.default(this.config.accountSid, this.config.authToken);
37
+ this.running = true;
38
+ console.log(`[Channel:WhatsApp] Ready (webhook: POST /webhooks/whatsapp)`);
39
+ if (this.config.allowlist?.length) {
40
+ console.log(`[Channel:WhatsApp] Allowlist active — ${this.config.allowlist.length} authorized number(s)`);
41
+ }
42
+ }
43
+
44
+ async stop() {
45
+ this.running = false;
46
+ console.log(`[Channel:WhatsApp] Stopped`);
47
+ }
48
+
49
+ /**
50
+ * Handle incoming webhook from Twilio.
51
+ * Called by Express route in index.js.
52
+ */
53
+ async handleWebhook(body) {
54
+ const from = body.From; // whatsapp:+1234567890
55
+ const text = body.Body || "";
56
+ const phone = from.replace("whatsapp:", "");
57
+ const numMedia = parseInt(body.NumMedia || "0", 10);
58
+
59
+ console.log(`[Channel:WhatsApp] Message from ${phone}: "${text?.slice(0, 80)}"${numMedia ? ` + ${numMedia} media` : ""}`);
60
+
61
+ if (!text && numMedia === 0) return null;
62
+
63
+ // Allowlist check (match against the phone number without "whatsapp:" prefix)
64
+ if (!this.isAllowed(phone)) {
65
+ console.log(`[Channel:WhatsApp] Blocked (not in allowlist): ${phone}`);
66
+ await this.sendReply({ phone, from }, "You are not authorized to use this agent.");
67
+ return "blocked";
68
+ }
69
+
70
+ // Build input from text + media attachments
71
+ const inputParts = text ? [text] : [];
72
+ for (let i = 0; i < numMedia; i++) {
73
+ const mediaUrl = body[`MediaUrl${i}`];
74
+ const mediaType = body[`MediaContentType${i}`] || "";
75
+ if (!mediaUrl) continue;
76
+
77
+ const localPath = await this._downloadMedia(mediaUrl, mediaType);
78
+ if (!localPath) continue;
79
+
80
+ if (mediaType.startsWith("audio/")) {
81
+ console.log(`[Channel:WhatsApp] Audio media — transcribing...`);
82
+ const transcript = await transcribeAudio(localPath);
83
+ inputParts.push(transcript.startsWith("Error:")
84
+ ? `[Audio file: ${localPath}]\n${transcript}`
85
+ : `[Voice/Audio transcript]: ${transcript}`);
86
+ } else if (mediaType.startsWith("image/")) {
87
+ inputParts.push(`[Photo received: ${localPath}]\nUser caption: ${text || "Describe and respond to this image."}`);
88
+ } else if (mediaType.startsWith("video/")) {
89
+ inputParts.push(`[Video received: ${localPath}]`);
90
+ } else {
91
+ inputParts.push(`[File received: ${localPath}]`);
92
+ }
93
+ }
94
+
95
+ const input = inputParts.join("\n");
96
+
97
+ // Create task
98
+ const task = taskQueue.enqueue({
99
+ input,
100
+ channel: "whatsapp",
101
+ channelMeta: { phone, from },
102
+ sessionId: this.getSessionId(phone),
103
+ model: this.getModel(),
104
+ });
105
+
106
+ // Wait for completion
107
+ const completedTask = await taskQueue.waitForCompletion(task.id);
108
+
109
+ // Absorbed into a concurrent session — response already sent via original task
110
+ if (this.isTaskMerged(completedTask)) return;
111
+
112
+ // Send reply via Twilio
113
+ const response = completedTask.status === "failed"
114
+ ? `Sorry, I encountered an error: ${completedTask.error}`
115
+ : completedTask.result || "Done.";
116
+
117
+ await this.sendReply({ phone, from }, response);
118
+ return response;
119
+ }
120
+
121
+ async sendReply(channelMeta, text) {
122
+ if (!this.twilioClient) return;
123
+
124
+ // WhatsApp message limit: ~1600 chars. Split if needed.
125
+ const chunks = splitText(text, 1600);
126
+ for (const chunk of chunks) {
127
+ await this.twilioClient.messages.create({
128
+ from: this.config.from,
129
+ to: channelMeta.from,
130
+ body: chunk,
131
+ });
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Send a media file to a WhatsApp number via Twilio.
137
+ * Requires a publicly accessible URL for the media.
138
+ * If PUBLIC_URL is configured, the file is served from the local HTTP server.
139
+ * Otherwise, this is a no-op with an informative log.
140
+ */
141
+ async sendFile(channelMeta, filePath, caption) {
142
+ if (!this.twilioClient) return;
143
+
144
+ const publicUrl = process.env.PUBLIC_URL;
145
+ if (!publicUrl) {
146
+ console.log(`[Channel:WhatsApp] sendFile: set PUBLIC_URL env var to serve media files`);
147
+ // Fall back to sending just a text notice
148
+ if (caption) await this.sendReply(channelMeta, caption);
149
+ return;
150
+ }
151
+
152
+ try {
153
+ const fileName = filePath.split("/").pop();
154
+ const mediaUrl = `${publicUrl.replace(/\/$/, "")}/media/${fileName}`;
155
+
156
+ await this.twilioClient.messages.create({
157
+ from: this.config.from,
158
+ to: channelMeta.from,
159
+ mediaUrl: [mediaUrl],
160
+ body: caption || undefined,
161
+ });
162
+ } catch (err) {
163
+ console.log(`[Channel:WhatsApp] sendFile error: ${err.message}`);
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Download Twilio media to /tmp using Basic auth (accountSid:authToken).
169
+ */
170
+ async _downloadMedia(mediaUrl, contentType) {
171
+ try {
172
+ const ext = _mimeToExt(contentType) || "";
173
+ const tmpDir = join(tmpdir(), "daemora-whatsapp");
174
+ mkdirSync(tmpDir, { recursive: true });
175
+ const fileName = `media-${Date.now()}${ext}`;
176
+ const filePath = join(tmpDir, fileName);
177
+
178
+ const auth = Buffer.from(`${this.config.accountSid}:${this.config.authToken}`).toString("base64");
179
+ const res = await fetch(mediaUrl, {
180
+ headers: { Authorization: `Basic ${auth}` },
181
+ signal: AbortSignal.timeout(30000),
182
+ });
183
+ if (!res.ok) return null;
184
+
185
+ const buffer = await res.arrayBuffer();
186
+ writeFileSync(filePath, Buffer.from(buffer));
187
+ return filePath;
188
+ } catch (err) {
189
+ console.log(`[Channel:WhatsApp] Media download error: ${err.message}`);
190
+ return null;
191
+ }
192
+ }
193
+ }
194
+
195
+ function _mimeToExt(mimeType) {
196
+ const map = {
197
+ "audio/ogg": ".ogg", "audio/mpeg": ".mp3", "audio/mp4": ".m4a",
198
+ "audio/wav": ".wav", "audio/webm": ".webm",
199
+ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp",
200
+ "video/mp4": ".mp4", "video/3gpp": ".3gp", "video/quicktime": ".mov",
201
+ "application/pdf": ".pdf",
202
+ };
203
+ return map[mimeType] || "";
204
+ }
205
+
206
+ function splitText(text, maxLength) {
207
+ if (text.length <= maxLength) return [text];
208
+ const chunks = [];
209
+ let remaining = text;
210
+ while (remaining.length > 0) {
211
+ if (remaining.length <= maxLength) { chunks.push(remaining); break; }
212
+ let idx = remaining.lastIndexOf("\n", maxLength);
213
+ if (idx < maxLength * 0.3) idx = remaining.lastIndexOf(" ", maxLength);
214
+ if (idx === -1) idx = maxLength;
215
+ chunks.push(remaining.slice(0, idx));
216
+ remaining = remaining.slice(idx).trimStart();
217
+ }
218
+ return chunks;
219
+ }