daemora 1.0.1 → 1.0.3

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 (134) hide show
  1. package/README.md +106 -76
  2. package/SOUL.md +100 -28
  3. package/config/mcp.json +9 -9
  4. package/package.json +15 -8
  5. package/skills/apple-notes.md +0 -52
  6. package/skills/apple-reminders.md +1 -87
  7. package/skills/camsnap.md +20 -144
  8. package/skills/coding.md +7 -7
  9. package/skills/documents.md +6 -6
  10. package/skills/email.md +6 -6
  11. package/skills/gif-search.md +28 -171
  12. package/skills/healthcheck.md +21 -203
  13. package/skills/image-gen.md +24 -123
  14. package/skills/model-usage.md +18 -165
  15. package/skills/obsidian.md +28 -174
  16. package/skills/pdf.md +30 -181
  17. package/skills/research.md +6 -6
  18. package/skills/skill-creator.md +35 -111
  19. package/skills/spotify.md +2 -17
  20. package/skills/summarize.md +36 -193
  21. package/skills/things.md +23 -175
  22. package/skills/tmux.md +1 -91
  23. package/skills/trello.md +32 -157
  24. package/skills/video-frames.md +26 -166
  25. package/skills/weather.md +6 -6
  26. package/src/a2a/A2AClient.js +2 -2
  27. package/src/a2a/A2AServer.js +6 -6
  28. package/src/a2a/AgentCard.js +2 -2
  29. package/src/agents/SubAgentManager.js +61 -19
  30. package/src/agents/Supervisor.js +4 -4
  31. package/src/channels/BaseChannel.js +6 -6
  32. package/src/channels/BlueBubblesChannel.js +112 -0
  33. package/src/channels/DiscordChannel.js +8 -8
  34. package/src/channels/EmailChannel.js +54 -26
  35. package/src/channels/FeishuChannel.js +140 -0
  36. package/src/channels/GoogleChatChannel.js +8 -8
  37. package/src/channels/HttpChannel.js +2 -2
  38. package/src/channels/IRCChannel.js +144 -0
  39. package/src/channels/LineChannel.js +13 -13
  40. package/src/channels/MatrixChannel.js +97 -0
  41. package/src/channels/MattermostChannel.js +119 -0
  42. package/src/channels/NextcloudChannel.js +133 -0
  43. package/src/channels/NostrChannel.js +175 -0
  44. package/src/channels/SignalChannel.js +9 -9
  45. package/src/channels/SlackChannel.js +10 -10
  46. package/src/channels/TeamsChannel.js +10 -10
  47. package/src/channels/TelegramChannel.js +8 -8
  48. package/src/channels/TwitchChannel.js +128 -0
  49. package/src/channels/WhatsAppChannel.js +10 -10
  50. package/src/channels/ZaloChannel.js +119 -0
  51. package/src/channels/iMessageChannel.js +150 -0
  52. package/src/channels/index.js +241 -11
  53. package/src/cli.js +835 -38
  54. package/src/config/agentProfiles.js +19 -19
  55. package/src/config/channels.js +1 -1
  56. package/src/config/default.js +12 -7
  57. package/src/config/models.js +3 -3
  58. package/src/config/permissions.js +2 -2
  59. package/src/core/AgentLoop.js +13 -13
  60. package/src/core/Compaction.js +3 -3
  61. package/src/core/CostTracker.js +2 -2
  62. package/src/core/EventBus.js +15 -15
  63. package/src/core/TaskQueue.js +24 -7
  64. package/src/core/TaskRunner.js +19 -6
  65. package/src/daemon/DaemonManager.js +4 -4
  66. package/src/hooks/HookRunner.js +4 -4
  67. package/src/index.js +6 -2
  68. package/src/mcp/MCPAgentRunner.js +3 -3
  69. package/src/mcp/MCPClient.js +9 -9
  70. package/src/mcp/MCPManager.js +14 -14
  71. package/src/models/ModelRouter.js +2 -2
  72. package/src/safety/AuditLog.js +3 -3
  73. package/src/safety/CircuitBreaker.js +2 -2
  74. package/src/safety/CommandGuard.js +132 -0
  75. package/src/safety/FilesystemGuard.js +23 -3
  76. package/src/safety/GitRollback.js +5 -5
  77. package/src/safety/HumanApproval.js +9 -9
  78. package/src/safety/InputSanitizer.js +81 -8
  79. package/src/safety/PermissionGuard.js +2 -2
  80. package/src/safety/Sandbox.js +1 -1
  81. package/src/safety/SecretScanner.js +90 -28
  82. package/src/safety/SecretVault.js +2 -2
  83. package/src/scheduler/Heartbeat.js +3 -3
  84. package/src/scheduler/Scheduler.js +6 -6
  85. package/src/setup/theme.js +171 -66
  86. package/src/setup/wizard.js +432 -57
  87. package/src/skills/SkillLoader.js +145 -8
  88. package/src/storage/TaskStore.js +39 -15
  89. package/src/systemPrompt.js +45 -43
  90. package/src/tenants/TenantManager.js +79 -22
  91. package/src/tools/ToolRegistry.js +3 -3
  92. package/src/tools/applyPatch.js +2 -2
  93. package/src/tools/browserAutomation.js +4 -4
  94. package/src/tools/calendar.js +155 -0
  95. package/src/tools/clipboard.js +71 -0
  96. package/src/tools/contacts.js +138 -0
  97. package/src/tools/createDocument.js +2 -2
  98. package/src/tools/cronTool.js +14 -14
  99. package/src/tools/database.js +165 -0
  100. package/src/tools/editFile.js +10 -10
  101. package/src/tools/executeCommand.js +11 -3
  102. package/src/tools/generateImage.js +79 -0
  103. package/src/tools/gitTool.js +141 -0
  104. package/src/tools/glob.js +1 -1
  105. package/src/tools/googlePlaces.js +136 -0
  106. package/src/tools/grep.js +2 -2
  107. package/src/tools/iMessageTool.js +86 -0
  108. package/src/tools/imageAnalysis.js +3 -3
  109. package/src/tools/index.js +56 -2
  110. package/src/tools/makeVoiceCall.js +283 -0
  111. package/src/tools/manageAgents.js +2 -2
  112. package/src/tools/manageMCP.js +38 -20
  113. package/src/tools/memory.js +25 -32
  114. package/src/tools/messageChannel.js +1 -1
  115. package/src/tools/notification.js +90 -0
  116. package/src/tools/philipsHue.js +147 -0
  117. package/src/tools/projectTracker.js +8 -8
  118. package/src/tools/readFile.js +1 -1
  119. package/src/tools/readPDF.js +73 -0
  120. package/src/tools/screenCapture.js +6 -6
  121. package/src/tools/searchContent.js +2 -2
  122. package/src/tools/searchFiles.js +1 -1
  123. package/src/tools/sendEmail.js +79 -24
  124. package/src/tools/sendFile.js +4 -4
  125. package/src/tools/sonos.js +137 -0
  126. package/src/tools/sshTool.js +130 -0
  127. package/src/tools/textToSpeech.js +5 -5
  128. package/src/tools/transcribeAudio.js +4 -4
  129. package/src/tools/useMCP.js +4 -4
  130. package/src/tools/webFetch.js +2 -2
  131. package/src/tools/webSearch.js +1 -1
  132. package/src/utils/Embeddings.js +79 -0
  133. package/src/voice/VoiceSessionManager.js +170 -0
  134. package/src/voice/VoiceWebhook.js +188 -0
@@ -7,9 +7,9 @@ import { join, extname, basename } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
8
 
9
9
  /**
10
- * Slack Channel receives messages via Slack Bolt + Socket Mode.
10
+ * Slack Channel - receives messages via Slack Bolt + Socket Mode.
11
11
  *
12
- * Socket Mode means NO public webhook URL needed works on any machine.
12
+ * Socket Mode means NO public webhook URL needed - works on any machine.
13
13
  *
14
14
  * Setup:
15
15
  * 1. Go to https://api.slack.com/apps → Create New App → From Scratch
@@ -25,10 +25,10 @@ import { tmpdir } from "node:os";
25
25
  * 5. Set env: SLACK_BOT_TOKEN, SLACK_APP_TOKEN
26
26
  *
27
27
  * Config:
28
- * botToken xoxb-... token
29
- * appToken xapp-... token for Socket Mode
30
- * allowlist Optional array of Slack user IDs (Uxxxxxxxx) allowed to use the bot
31
- * model Optional model override
28
+ * botToken - xoxb-... token
29
+ * appToken - xapp-... token for Socket Mode
30
+ * allowlist - Optional array of Slack user IDs (Uxxxxxxxx) allowed to use the bot
31
+ * model - Optional model override
32
32
  *
33
33
  * The bot responds to:
34
34
  * - Direct messages (message.im)
@@ -43,7 +43,7 @@ export class SlackChannel extends BaseChannel {
43
43
 
44
44
  async start() {
45
45
  if (!this.config.botToken || !this.config.appToken) {
46
- console.log(`[Channel:Slack] Skipped need SLACK_BOT_TOKEN and SLACK_APP_TOKEN`);
46
+ console.log(`[Channel:Slack] Skipped - need SLACK_BOT_TOKEN and SLACK_APP_TOKEN`);
47
47
  return;
48
48
  }
49
49
 
@@ -119,7 +119,7 @@ export class SlackChannel extends BaseChannel {
119
119
  this.running = true;
120
120
  console.log(`[Channel:Slack] Started (Socket Mode)`);
121
121
  if (this.config.allowlist?.length) {
122
- console.log(`[Channel:Slack] Allowlist active ${this.config.allowlist.length} authorized user(s)`);
122
+ console.log(`[Channel:Slack] Allowlist active - ${this.config.allowlist.length} authorized user(s)`);
123
123
  }
124
124
  } catch (err) {
125
125
  console.log(`[Channel:Slack] Failed to start: ${err.message}`);
@@ -147,7 +147,7 @@ export class SlackChannel extends BaseChannel {
147
147
 
148
148
  const mimeType = file.mimetype || "";
149
149
  if (mimeType.startsWith("audio/")) {
150
- console.log(`[Channel:Slack] Audio file transcribing...`);
150
+ console.log(`[Channel:Slack] Audio file - transcribing...`);
151
151
  const transcript = await transcribeAudio(localPath);
152
152
  inputParts.push(transcript.startsWith("Error:")
153
153
  ? `[Audio file: ${localPath}]\n${transcript}`
@@ -174,7 +174,7 @@ export class SlackChannel extends BaseChannel {
174
174
  try {
175
175
  const completedTask = await taskQueue.waitForCompletion(task.id);
176
176
 
177
- // Absorbed into a concurrent session response already sent via original task
177
+ // Absorbed into a concurrent session - response already sent via original task
178
178
  if (this.isTaskMerged(completedTask)) {
179
179
  await this._removeReaction(channelId, messageTs, "hourglass_flowing_sand");
180
180
  await this._addReaction(channelId, messageTs, "white_check_mark");
@@ -6,7 +6,7 @@ import { join, extname } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
 
8
8
  /**
9
- * Microsoft Teams Channel receives messages via Bot Framework v4 (CloudAdapter).
9
+ * Microsoft Teams Channel - receives messages via Bot Framework v4 (CloudAdapter).
10
10
  *
11
11
  * Setup:
12
12
  * 1. Go to https://portal.azure.com → Create a resource → Azure Bot
@@ -16,10 +16,10 @@ import { tmpdir } from "node:os";
16
16
  * 5. In the bot resource → Channels → Add Microsoft Teams
17
17
  *
18
18
  * Config:
19
- * appId Microsoft App ID from Azure Bot registration
20
- * appPassword Client secret from Azure AD app registration
21
- * allowlist Optional array of Teams user IDs / AAD object IDs
22
- * model Optional model override
19
+ * appId - Microsoft App ID from Azure Bot registration
20
+ * appPassword - Client secret from Azure AD app registration
21
+ * allowlist - Optional array of Teams user IDs / AAD object IDs
22
+ * model - Optional model override
23
23
  *
24
24
  * Unlike OpenClaw's 461-line channel with Adaptive Cards and Graph API,
25
25
  * this is minimal: text messages + file attachments + proactive reply.
@@ -36,7 +36,7 @@ export class TeamsChannel extends BaseChannel {
36
36
 
37
37
  async start() {
38
38
  if (!this.config.appId || !this.config.appPassword) {
39
- console.log(`[Channel:Teams] Skipped set TEAMS_APP_ID and TEAMS_APP_PASSWORD`);
39
+ console.log(`[Channel:Teams] Skipped - set TEAMS_APP_ID and TEAMS_APP_PASSWORD`);
40
40
  return;
41
41
  }
42
42
 
@@ -63,7 +63,7 @@ export class TeamsChannel extends BaseChannel {
63
63
  this.running = true;
64
64
  console.log(`[Channel:Teams] Ready (webhook: POST /webhooks/teams)`);
65
65
  if (this.config.allowlist?.length) {
66
- console.log(`[Channel:Teams] Allowlist active ${this.config.allowlist.length} authorized user(s)`);
66
+ console.log(`[Channel:Teams] Allowlist active - ${this.config.allowlist.length} authorized user(s)`);
67
67
  }
68
68
  }
69
69
 
@@ -148,10 +148,10 @@ export class TeamsChannel extends BaseChannel {
148
148
  const ref = this._TurnContext.getConversationReference(context.activity);
149
149
  this._conversationRefs.set(userId, ref);
150
150
 
151
- // Ack with typing indicator keeps Teams from showing "delivery failed"
151
+ // Ack with typing indicator - keeps Teams from showing "delivery failed"
152
152
  await context.sendActivity({ type: "typing" });
153
153
 
154
- // Enqueue task and reply proactively (don't await we must return within 5s)
154
+ // Enqueue task and reply proactively (don't await - we must return within 5s)
155
155
  const task = taskQueue.enqueue({
156
156
  input,
157
157
  channel: "teams",
@@ -209,7 +209,7 @@ export class TeamsChannel extends BaseChannel {
209
209
  try {
210
210
  // Teams file sending requires SharePoint upload for larger files.
211
211
  // For simplicity: send the file path as text with caption.
212
- // Full file upload requires Graph API + bot permissions out of scope here.
212
+ // Full file upload requires Graph API + bot permissions - out of scope here.
213
213
  const msg = caption
214
214
  ? `${caption}\n(File: ${filePath})`
215
215
  : `File: ${filePath}`;
@@ -6,7 +6,7 @@ import { join, extname } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
 
8
8
  /**
9
- * Telegram Channel receives messages via Telegram Bot API (grammy).
9
+ * Telegram Channel - receives messages via Telegram Bot API (grammy).
10
10
  *
11
11
  * Handles:
12
12
  * ✅ Text messages
@@ -23,9 +23,9 @@ import { tmpdir } from "node:os";
23
23
  * ✅ Documents (any other file)
24
24
  *
25
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
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
29
  */
30
30
  export class TelegramChannel extends BaseChannel {
31
31
  constructor(config) {
@@ -38,7 +38,7 @@ export class TelegramChannel extends BaseChannel {
38
38
  this._InputFile = InputFile;
39
39
 
40
40
  if (!this.config.token) {
41
- console.log(`[Channel:Telegram] Skipped no TELEGRAM_BOT_TOKEN`);
41
+ console.log(`[Channel:Telegram] Skipped - no TELEGRAM_BOT_TOKEN`);
42
42
  return;
43
43
  }
44
44
 
@@ -78,7 +78,7 @@ export class TelegramChannel extends BaseChannel {
78
78
  return;
79
79
  }
80
80
 
81
- console.log(`[Channel:Telegram] Voice from ${userName} (${chatId}) transcribing...`);
81
+ console.log(`[Channel:Telegram] Voice from ${userName} (${chatId}) - transcribing...`);
82
82
  await ctx.replyWithChatAction("typing");
83
83
  await this.sendReaction({ chatId, messageId: ctx.message.message_id }, "⏳");
84
84
 
@@ -198,7 +198,7 @@ export class TelegramChannel extends BaseChannel {
198
198
  this.running = true;
199
199
  console.log(`[Channel:Telegram] Started as @${me.username}`);
200
200
  if (this.config.allowlist?.length) {
201
- console.log(`[Channel:Telegram] Allowlist active ${this.config.allowlist.length} authorized chat(s)`);
201
+ console.log(`[Channel:Telegram] Allowlist active - ${this.config.allowlist.length} authorized chat(s)`);
202
202
  }
203
203
  }
204
204
 
@@ -218,7 +218,7 @@ export class TelegramChannel extends BaseChannel {
218
218
  try {
219
219
  const completedTask = await taskQueue.waitForCompletion(task.id);
220
220
 
221
- // Task was absorbed into a concurrent agent session response already sent
221
+ // Task was absorbed into a concurrent agent session - response already sent
222
222
  if (this.isTaskMerged(completedTask)) {
223
223
  await this.sendReaction({ chatId, messageId }, "✅");
224
224
  return;
@@ -0,0 +1,128 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+
4
+ /**
5
+ * Twitch Channel - receives chat commands via Twitch IRC (TMI).
6
+ *
7
+ * Setup:
8
+ * 1. Create a Twitch app at https://dev.twitch.tv/console
9
+ * 2. Generate an OAuth token for the bot account (chat:read, chat:edit scope)
10
+ * Use: https://twitchapps.com/tmi/
11
+ * 3. Set env: TWITCH_BOT_USERNAME, TWITCH_OAUTH_TOKEN, TWITCH_CHANNEL
12
+ *
13
+ * The bot responds to "!ask <question>" or "@botname <question>" in chat.
14
+ *
15
+ * Config:
16
+ * username - Bot Twitch username
17
+ * token - OAuth token (without "oauth:" prefix)
18
+ * channel - Twitch channel to join (without #)
19
+ * prefix - Command prefix, default "!ask"
20
+ * allowlist - Optional array of Twitch usernames (mods, subscribers, etc.)
21
+ * model - Optional model override
22
+ */
23
+ export class TwitchChannel extends BaseChannel {
24
+ constructor(config) {
25
+ super("twitch", config);
26
+ this.ws = null;
27
+ this.channel = (config.channel || "").toLowerCase();
28
+ this.prefix = config.prefix || "!ask";
29
+ }
30
+
31
+ async start() {
32
+ if (!this.config.username || !this.config.token || !this.channel) {
33
+ console.log("[Channel:Twitch] Skipped - missing TWITCH_BOT_USERNAME, TWITCH_OAUTH_TOKEN, or TWITCH_CHANNEL");
34
+ return;
35
+ }
36
+
37
+ try {
38
+ const { WebSocket } = await import("ws");
39
+ this.ws = new WebSocket("wss://irc-ws.chat.twitch.tv:443");
40
+
41
+ this.ws.on("open", () => {
42
+ this.ws.send(`PASS oauth:${this.config.token}`);
43
+ this.ws.send(`NICK ${this.config.username}`);
44
+ this.ws.send(`JOIN #${this.channel}`);
45
+ this.running = true;
46
+ console.log(`[Channel:Twitch] Connected to #${this.channel}`);
47
+ });
48
+
49
+ this.ws.on("message", async (raw) => {
50
+ const line = raw.toString().trim();
51
+
52
+ // Keep alive
53
+ if (line.startsWith("PING")) {
54
+ this.ws.send("PONG :tmi.twitch.tv");
55
+ return;
56
+ }
57
+
58
+ // Parse: :user!user@user.tmi.twitch.tv PRIVMSG #channel :message
59
+ const match = line.match(/^:(\w+)!\w+@\w+\.tmi\.twitch\.tv PRIVMSG #(\w+) :(.+)$/);
60
+ if (!match) return;
61
+
62
+ const [, username, , message] = match;
63
+ if (username.toLowerCase() === this.config.username.toLowerCase()) return;
64
+
65
+ // Check for command prefix or @mention
66
+ const mentioned = message.toLowerCase().includes(`@${this.config.username.toLowerCase()}`);
67
+ const hasPrefix = message.toLowerCase().startsWith(this.prefix.toLowerCase());
68
+ if (!hasPrefix && !mentioned) return;
69
+
70
+ if (!this.isAllowed(username)) {
71
+ this._sendChat(`@${username} Sorry, you're not on the allowlist.`);
72
+ return;
73
+ }
74
+
75
+ const input = message
76
+ .replace(new RegExp(`^${this.prefix}\\s*`, "i"), "")
77
+ .replace(new RegExp(`@${this.config.username}\\s*`, "ig"), "")
78
+ .trim();
79
+
80
+ if (!input) return;
81
+
82
+ const channelMeta = { channel: this.channel, username };
83
+
84
+ const task = await taskQueue.enqueue({
85
+ input,
86
+ channel: "twitch",
87
+ sessionId: this.getSessionId(username),
88
+ channelMeta,
89
+ model: this.getModel(),
90
+ });
91
+
92
+ const result = await taskQueue.waitForResult(task.id);
93
+ if (!this.isTaskMerged(result)) {
94
+ // Twitch chat max 500 chars per message
95
+ const reply = (result.result || "(no response)").slice(0, 490);
96
+ this._sendChat(`@${username} ${reply}`);
97
+ }
98
+ });
99
+
100
+ this.ws.on("error", (err) => console.log(`[Channel:Twitch] WS error: ${err.message}`));
101
+ this.ws.on("close", () => {
102
+ this.running = false;
103
+ console.log("[Channel:Twitch] Disconnected");
104
+ });
105
+ } catch (err) {
106
+ console.log(`[Channel:Twitch] Failed to start: ${err.message}`);
107
+ }
108
+ }
109
+
110
+ _sendChat(text) {
111
+ if (this.ws?.readyState === 1) {
112
+ this.ws.send(`PRIVMSG #${this.channel} :${text.slice(0, 490)}`);
113
+ }
114
+ }
115
+
116
+ async stop() {
117
+ if (this.ws) {
118
+ this.ws.close();
119
+ this.running = false;
120
+ }
121
+ console.log("[Channel:Twitch] Stopped");
122
+ }
123
+
124
+ async sendReply(channelMeta, text) {
125
+ const prefix = channelMeta?.username ? `@${channelMeta.username} ` : "";
126
+ this._sendChat(`${prefix}${text.slice(0, 490 - prefix.length)}`);
127
+ }
128
+ }
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
 
8
8
  /**
9
- * WhatsApp Channel receives messages via Twilio webhook.
9
+ * WhatsApp Channel - receives messages via Twilio webhook.
10
10
  *
11
11
  * Setup:
12
12
  * 1. Create Twilio account + WhatsApp sandbox
@@ -14,11 +14,11 @@ import { tmpdir } from "node:os";
14
14
  * 3. Set env: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_WHATSAPP_FROM
15
15
  *
16
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
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
22
  */
23
23
  export class WhatsAppChannel extends BaseChannel {
24
24
  constructor(config) {
@@ -28,7 +28,7 @@ export class WhatsAppChannel extends BaseChannel {
28
28
 
29
29
  async start() {
30
30
  if (!this.config.accountSid || !this.config.authToken) {
31
- console.log(`[Channel:WhatsApp] Skipped no Twilio credentials`);
31
+ console.log(`[Channel:WhatsApp] Skipped - no Twilio credentials`);
32
32
  return;
33
33
  }
34
34
 
@@ -37,7 +37,7 @@ export class WhatsAppChannel extends BaseChannel {
37
37
  this.running = true;
38
38
  console.log(`[Channel:WhatsApp] Ready (webhook: POST /webhooks/whatsapp)`);
39
39
  if (this.config.allowlist?.length) {
40
- console.log(`[Channel:WhatsApp] Allowlist active ${this.config.allowlist.length} authorized number(s)`);
40
+ console.log(`[Channel:WhatsApp] Allowlist active - ${this.config.allowlist.length} authorized number(s)`);
41
41
  }
42
42
  }
43
43
 
@@ -78,7 +78,7 @@ export class WhatsAppChannel extends BaseChannel {
78
78
  if (!localPath) continue;
79
79
 
80
80
  if (mediaType.startsWith("audio/")) {
81
- console.log(`[Channel:WhatsApp] Audio media transcribing...`);
81
+ console.log(`[Channel:WhatsApp] Audio media - transcribing...`);
82
82
  const transcript = await transcribeAudio(localPath);
83
83
  inputParts.push(transcript.startsWith("Error:")
84
84
  ? `[Audio file: ${localPath}]\n${transcript}`
@@ -106,7 +106,7 @@ export class WhatsAppChannel extends BaseChannel {
106
106
  // Wait for completion
107
107
  const completedTask = await taskQueue.waitForCompletion(task.id);
108
108
 
109
- // Absorbed into a concurrent session response already sent via original task
109
+ // Absorbed into a concurrent session - response already sent via original task
110
110
  if (this.isTaskMerged(completedTask)) return;
111
111
 
112
112
  // Send reply via Twilio
@@ -0,0 +1,119 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+
4
+ /**
5
+ * Zalo Channel - receives messages via Zalo Official Account API.
6
+ * Popular messaging platform in Vietnam (~75M users).
7
+ *
8
+ * Setup:
9
+ * 1. Create a Zalo Official Account at https://oa.zalo.me
10
+ * 2. Register an app and get App ID + App Secret
11
+ * 3. Get an access token via OAuth or ZCA token
12
+ * 4. Set env: ZALO_APP_ID, ZALO_APP_SECRET, ZALO_ACCESS_TOKEN
13
+ * 5. Configure webhook URL: https://your-domain.com/channels/zalo
14
+ *
15
+ * Config:
16
+ * appId - Zalo App ID
17
+ * appSecret - Zalo App Secret
18
+ * accessToken - Zalo OA access token
19
+ * port - Webhook port (default 3005)
20
+ * allowlist - Optional array of Zalo user IDs
21
+ * model - Optional model override
22
+ */
23
+ export class ZaloChannel extends BaseChannel {
24
+ constructor(config) {
25
+ super("zalo", config);
26
+ this.server = null;
27
+ }
28
+
29
+ async start() {
30
+ if (!this.config.appId || !this.config.accessToken) {
31
+ console.log("[Channel:Zalo] Skipped - missing ZALO_APP_ID or ZALO_ACCESS_TOKEN");
32
+ return;
33
+ }
34
+
35
+ const { createServer } = await import("node:http");
36
+
37
+ this.server = createServer(async (req, res) => {
38
+ if (req.url !== "/channels/zalo") {
39
+ res.writeHead(404).end();
40
+ return;
41
+ }
42
+
43
+ // Zalo sends GET for webhook verification
44
+ if (req.method === "GET") {
45
+ const url = new URL(req.url, `http://localhost`);
46
+ const challenge = url.searchParams.get("challenge");
47
+ res.writeHead(200, { "Content-Type": "text/plain" });
48
+ res.end(challenge || "ok");
49
+ return;
50
+ }
51
+
52
+ if (req.method !== "POST") {
53
+ res.writeHead(405).end();
54
+ return;
55
+ }
56
+
57
+ let body = "";
58
+ req.on("data", d => body += d);
59
+ req.on("end", async () => {
60
+ res.writeHead(200).end("ok");
61
+ try {
62
+ const payload = JSON.parse(body);
63
+ if (payload.event_name !== "user_send_text") return;
64
+
65
+ const senderId = payload.sender?.id;
66
+ if (!senderId || !this.isAllowed(senderId)) return;
67
+
68
+ const input = payload.message?.text?.trim();
69
+ if (!input) return;
70
+
71
+ const channelMeta = { senderId };
72
+ const task = await taskQueue.enqueue({
73
+ input,
74
+ channel: "zalo",
75
+ sessionId: this.getSessionId(senderId),
76
+ channelMeta,
77
+ model: this.getModel(),
78
+ });
79
+
80
+ const result = await taskQueue.waitForResult(task.id);
81
+ if (!this.isTaskMerged(result)) {
82
+ await this.sendReply(channelMeta, result.result || "(no response)");
83
+ }
84
+ } catch (err) {
85
+ console.log(`[Channel:Zalo] Error: ${err.message}`);
86
+ }
87
+ });
88
+ });
89
+
90
+ const port = this.config.port || 3005;
91
+ await new Promise(resolve => this.server.listen(port, resolve));
92
+ this.running = true;
93
+ console.log(`[Channel:Zalo] Webhook listening on port ${port}/channels/zalo`);
94
+ }
95
+
96
+ async stop() {
97
+ if (this.server) {
98
+ await new Promise(resolve => this.server.close(resolve));
99
+ this.running = false;
100
+ }
101
+ console.log("[Channel:Zalo] Stopped");
102
+ }
103
+
104
+ async sendReply(channelMeta, text) {
105
+ if (!channelMeta?.senderId) return;
106
+ const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
107
+ await fetchFn(`https://openapi.zalo.me/v3.0/oa/message/cs`, {
108
+ method: "POST",
109
+ headers: {
110
+ "access_token": this.config.accessToken,
111
+ "Content-Type": "application/json",
112
+ },
113
+ body: JSON.stringify({
114
+ recipient: { user_id: channelMeta.senderId },
115
+ message: { text: text.slice(0, 2000) },
116
+ }),
117
+ });
118
+ }
119
+ }
@@ -0,0 +1,150 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+ import { execSync } from "node:child_process";
4
+
5
+ /**
6
+ * iMessage Channel - send/receive iMessages on macOS via AppleScript.
7
+ *
8
+ * Setup:
9
+ * 1. Must run on macOS with Messages app configured
10
+ * 2. Grant Accessibility and Automation permissions to Terminal/Node
11
+ * 3. Set env: IMESSAGE_POLL_INTERVAL_MS (default 5000)
12
+ * Optional: IMESSAGE_ALLOWLIST - comma-separated phone numbers/emails
13
+ *
14
+ * Note: Polling-based (reads latest unread messages periodically).
15
+ * This is a best-effort implementation — macOS AppleScript access to iMessages
16
+ * has limitations; consider BlueBubbles channel for more reliable access.
17
+ *
18
+ * Config:
19
+ * pollIntervalMs - How often to check for new messages (default 5000)
20
+ * allowlist - Optional array of phone numbers/emails
21
+ * model - Optional model override
22
+ */
23
+ export class iMessageChannel extends BaseChannel {
24
+ constructor(config) {
25
+ super("imessage", config);
26
+ this.pollInterval = null;
27
+ this.pollMs = config.pollIntervalMs || 5000;
28
+ this.lastChecked = new Date();
29
+ this.processedIds = new Set();
30
+ }
31
+
32
+ async start() {
33
+ if (process.platform !== "darwin") {
34
+ console.log("[Channel:iMessage] Skipped - macOS only");
35
+ return;
36
+ }
37
+
38
+ try {
39
+ // Test that osascript can access Messages
40
+ execSync("osascript -e 'tell application \"Messages\" to count chats'", { timeout: 5000 });
41
+ } catch {
42
+ console.log("[Channel:iMessage] Skipped - cannot access Messages app (check Accessibility permissions)");
43
+ return;
44
+ }
45
+
46
+ this.running = true;
47
+ console.log(`[Channel:iMessage] Started polling every ${this.pollMs}ms`);
48
+
49
+ this.pollInterval = setInterval(() => this._poll(), this.pollMs);
50
+ }
51
+
52
+ async _poll() {
53
+ try {
54
+ const script = `
55
+ tell application "Messages"
56
+ set output to {}
57
+ repeat with aChat in chats
58
+ if (count of messages of aChat) > 0 then
59
+ set lastMsg to last message of aChat
60
+ set msgDate to date sent of lastMsg
61
+ set msgId to id of lastMsg
62
+ if msgDate > date "${this.lastChecked.toLocaleString()}" then
63
+ if incoming of lastMsg then
64
+ set msgContent to content of lastMsg
65
+ set senderId to handle id of sender of lastMsg
66
+ set end of output to msgId & "|" & senderId & "|" & msgContent
67
+ end if
68
+ end if
69
+ end if
70
+ end repeat
71
+ return output
72
+ end tell
73
+ `;
74
+
75
+ let raw;
76
+ try {
77
+ raw = execSync(`osascript -e '${script.replace(/'/g, "\\'")}'`, {
78
+ encoding: "utf-8", timeout: 10000
79
+ });
80
+ } catch { return; }
81
+
82
+ this.lastChecked = new Date();
83
+ if (!raw.trim()) return;
84
+
85
+ const messages = raw.trim().split(", ");
86
+ for (const msg of messages) {
87
+ const [id, sender, ...contentParts] = msg.split("|");
88
+ const content = contentParts.join("|").trim();
89
+ if (!id || !sender || !content) continue;
90
+ if (this.processedIds.has(id)) continue;
91
+ this.processedIds.add(id);
92
+
93
+ if (!this.isAllowed(sender)) continue;
94
+
95
+ const channelMeta = { sender, chatId: sender };
96
+ const task = await taskQueue.enqueue({
97
+ input: content,
98
+ channel: "imessage",
99
+ sessionId: this.getSessionId(sender),
100
+ channelMeta,
101
+ model: this.getModel(),
102
+ });
103
+
104
+ taskQueue.waitForResult(task.id).then(result => {
105
+ if (!this.isTaskMerged(result)) {
106
+ this.sendReply(channelMeta, result.result || "(no response)");
107
+ }
108
+ }).catch(() => {});
109
+ }
110
+
111
+ // Keep processedIds from growing unbounded
112
+ if (this.processedIds.size > 1000) {
113
+ const arr = [...this.processedIds];
114
+ this.processedIds = new Set(arr.slice(-500));
115
+ }
116
+ } catch (err) {
117
+ // Silent — polling failures are expected occasionally
118
+ }
119
+ }
120
+
121
+ async stop() {
122
+ if (this.pollInterval) {
123
+ clearInterval(this.pollInterval);
124
+ this.pollInterval = null;
125
+ }
126
+ this.running = false;
127
+ console.log("[Channel:iMessage] Stopped");
128
+ }
129
+
130
+ async sendReply(channelMeta, text) {
131
+ if (!channelMeta?.sender) return;
132
+ const sender = channelMeta.sender;
133
+ // Split into chunks (AppleScript may time out on very long strings)
134
+ const chunks = text.match(/[\s\S]{1,1000}/g) || [text];
135
+ for (const chunk of chunks) {
136
+ const script = `
137
+ tell application "Messages"
138
+ set targetService to 1st service whose service type = iMessage
139
+ set targetBuddy to buddy "${sender.replace(/"/g, '\\"')}" of targetService
140
+ send "${chunk.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}" to targetBuddy
141
+ end tell
142
+ `;
143
+ try {
144
+ execSync(`osascript -e '${script.replace(/'/g, "\\'")}'`, { timeout: 10000 });
145
+ } catch (err) {
146
+ console.log(`[Channel:iMessage] Send error: ${err.message}`);
147
+ }
148
+ }
149
+ }
150
+ }