daemora 1.0.2 → 1.0.4

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 +834 -37
  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 +13 -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
@@ -0,0 +1,112 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+
4
+ /**
5
+ * BlueBubbles Channel - connects to a BlueBubbles server for iMessage relay.
6
+ * BlueBubbles runs on a Mac and exposes a REST + WebSocket API for iMessages.
7
+ *
8
+ * Setup:
9
+ * 1. Install BlueBubbles on a Mac: https://bluebubbles.app
10
+ * 2. Enable the server and note the server URL + password
11
+ * 3. Set env: BLUEBUBBLES_URL, BLUEBUBBLES_PASSWORD
12
+ *
13
+ * Config:
14
+ * url - BlueBubbles server URL (e.g. http://192.168.1.100:1234)
15
+ * password - BlueBubbles server password
16
+ * allowlist - Optional array of phone numbers / email addresses
17
+ * model - Optional model override
18
+ */
19
+ export class BlueBubblesChannel extends BaseChannel {
20
+ constructor(config) {
21
+ super("bluebubbles", config);
22
+ this.ws = null;
23
+ }
24
+
25
+ get _baseUrl() {
26
+ return this.config.url?.replace(/\/$/, "");
27
+ }
28
+
29
+ async start() {
30
+ if (!this.config.url || !this.config.password) {
31
+ console.log("[Channel:BlueBubbles] Skipped - missing BLUEBUBBLES_URL or BLUEBUBBLES_PASSWORD");
32
+ return;
33
+ }
34
+
35
+ try {
36
+ const { WebSocket } = await import("ws");
37
+ const wsUrl = this._baseUrl.replace(/^http/, "ws") + `/api/v1/socket.io/?password=${encodeURIComponent(this.config.password)}&transport=websocket`;
38
+
39
+ this.ws = new WebSocket(wsUrl);
40
+
41
+ this.ws.on("open", () => {
42
+ this.running = true;
43
+ console.log(`[Channel:BlueBubbles] Connected to ${this._baseUrl}`);
44
+ });
45
+
46
+ this.ws.on("message", async (raw) => {
47
+ let msg;
48
+ try { msg = JSON.parse(raw); } catch { return; }
49
+
50
+ // BlueBubbles sends Socket.IO-style messages
51
+ if (!msg?.event || msg.event !== "new-message") return;
52
+ const data = msg.data;
53
+ if (!data || data.isFromMe) return;
54
+
55
+ const sender = data.handle?.id || data.chats?.[0]?.participants?.[0]?.id;
56
+ if (!sender || !this.isAllowed(sender)) return;
57
+
58
+ const input = data.text?.trim();
59
+ if (!input) return;
60
+
61
+ const chatGuid = data.chats?.[0]?.guid;
62
+ const channelMeta = { chatGuid, sender, messageGuid: data.guid };
63
+
64
+ const task = await taskQueue.enqueue({
65
+ input,
66
+ channel: "bluebubbles",
67
+ sessionId: this.getSessionId(sender),
68
+ channelMeta,
69
+ model: this.getModel(),
70
+ });
71
+
72
+ const result = await taskQueue.waitForResult(task.id);
73
+ if (!this.isTaskMerged(result)) {
74
+ await this.sendReply(channelMeta, result.result || "(no response)");
75
+ }
76
+ });
77
+
78
+ this.ws.on("error", (err) => console.log(`[Channel:BlueBubbles] WS error: ${err.message}`));
79
+ this.ws.on("close", () => {
80
+ this.running = false;
81
+ console.log("[Channel:BlueBubbles] Disconnected");
82
+ });
83
+ } catch (err) {
84
+ console.log(`[Channel:BlueBubbles] Failed to start: ${err.message}`);
85
+ }
86
+ }
87
+
88
+ async stop() {
89
+ if (this.ws) {
90
+ this.ws.close();
91
+ this.running = false;
92
+ }
93
+ console.log("[Channel:BlueBubbles] Stopped");
94
+ }
95
+
96
+ async sendReply(channelMeta, text) {
97
+ if (!channelMeta?.chatGuid) return;
98
+ const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
99
+ await fetchFn(`${this._baseUrl}/api/v1/message/text`, {
100
+ method: "POST",
101
+ headers: {
102
+ "Content-Type": "application/json",
103
+ "Authorization": `Basic ${Buffer.from(`:${this.config.password}`).toString("base64")}`,
104
+ },
105
+ body: JSON.stringify({
106
+ chatGuid: channelMeta.chatGuid,
107
+ message: text,
108
+ method: "private-api",
109
+ }),
110
+ });
111
+ }
112
+ }
@@ -7,7 +7,7 @@ import { join, extname, basename } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
8
 
9
9
  /**
10
- * Discord Channel receives messages via Discord Bot API.
10
+ * Discord Channel - receives messages via Discord Bot API.
11
11
  *
12
12
  * Setup:
13
13
  * 1. Go to https://discord.com/developers/applications
@@ -17,9 +17,9 @@ import { tmpdir } from "node:os";
17
17
  * 5. Set env: DISCORD_BOT_TOKEN
18
18
  *
19
19
  * Config:
20
- * token Bot token
21
- * allowlist Optional array of Discord user IDs (snowflakes) allowed to use the bot
22
- * model Optional model override
20
+ * token - Bot token
21
+ * allowlist - Optional array of Discord user IDs (snowflakes) allowed to use the bot
22
+ * model - Optional model override
23
23
  *
24
24
  * The bot responds to:
25
25
  * - Direct messages (DMs)
@@ -34,7 +34,7 @@ export class DiscordChannel extends BaseChannel {
34
34
 
35
35
  async start() {
36
36
  if (!this.config.token) {
37
- console.log(`[Channel:Discord] Skipped no DISCORD_BOT_TOKEN`);
37
+ console.log(`[Channel:Discord] Skipped - no DISCORD_BOT_TOKEN`);
38
38
  return;
39
39
  }
40
40
 
@@ -58,7 +58,7 @@ export class DiscordChannel extends BaseChannel {
58
58
  this.running = true;
59
59
  console.log(`[Channel:Discord] Logged in as ${c.user.tag}`);
60
60
  if (this.config.allowlist?.length) {
61
- console.log(`[Channel:Discord] Allowlist active ${this.config.allowlist.length} authorized user(s)`);
61
+ console.log(`[Channel:Discord] Allowlist active - ${this.config.allowlist.length} authorized user(s)`);
62
62
  }
63
63
  });
64
64
 
@@ -111,7 +111,7 @@ export class DiscordChannel extends BaseChannel {
111
111
 
112
112
  const ct = attachment.contentType || "";
113
113
  if (ct.startsWith("audio/")) {
114
- console.log(`[Channel:Discord] Audio attachment transcribing...`);
114
+ console.log(`[Channel:Discord] Audio attachment - transcribing...`);
115
115
  const transcript = await transcribeAudio(localPath);
116
116
  inputParts.push(transcript.startsWith("Error:")
117
117
  ? `[Audio file: ${localPath}]\n${transcript}`
@@ -139,7 +139,7 @@ export class DiscordChannel extends BaseChannel {
139
139
  try {
140
140
  const completedTask = await taskQueue.waitForCompletion(task.id);
141
141
 
142
- // Absorbed into a concurrent session response already sent via original task
142
+ // Absorbed into a concurrent session - response already sent via original task
143
143
  if (this.isTaskMerged(completedTask)) {
144
144
  await this._removeReaction(message, "⏳");
145
145
  await this.sendReaction({ message }, "✅");
@@ -2,47 +2,75 @@ import { BaseChannel } from "./BaseChannel.js";
2
2
  import taskQueue from "../core/TaskQueue.js";
3
3
 
4
4
  /**
5
- * Email Channel polls IMAP for incoming emails, replies via SMTP.
5
+ * Email Channel - polls IMAP for incoming emails, replies via SMTP or Resend.
6
6
  *
7
- * Setup:
8
- * 1. Enable IMAP on your Gmail (or email provider)
9
- * 2. Set env: EMAIL_USER, EMAIL_PASSWORD, EMAIL_IMAP_HOST, EMAIL_SMTP_HOST
10
- * 3. For Gmail: use App Password (not regular password)
7
+ * Two setup options (can combine both):
8
+ *
9
+ * OPTION A - Resend (recommended, easiest):
10
+ * RESEND_API_KEY=re_xxxx → outbound sending via Resend
11
+ * RESEND_FROM=you@yourdomain.com → the "from" address (must be verified in Resend)
12
+ *
13
+ * OPTION B - Gmail IMAP/SMTP (traditional):
14
+ * EMAIL_USER=you@gmail.com
15
+ * EMAIL_PASSWORD=xxxx-xxxx-xxxx-xxxx ← Gmail App Password (not your real password)
16
+ * (IMAP/SMTP hosts default to Gmail - no need to set those)
17
+ *
18
+ * For full email agent (receive AND send), combine both or use Gmail IMAP/SMTP.
19
+ * Resend only handles outbound - you still need EMAIL_USER+PASSWORD for IMAP inbox polling.
11
20
  */
12
21
  export class EmailChannel extends BaseChannel {
13
22
  constructor(config) {
14
23
  super("email", config);
15
24
  this.transporter = null;
25
+ this.fromAddress = null;
16
26
  this.pollTimer = null;
17
27
  this.processedIds = new Set();
18
28
  }
19
29
 
20
30
  async start() {
21
- if (!this.config.user || !this.config.password) {
22
- console.log(`[Channel:Email] Skipped no EMAIL_USER/EMAIL_PASSWORD`);
31
+ const hasResend = !!this.config.resendApiKey;
32
+ const hasSmtp = !!(this.config.user && this.config.password);
33
+ const hasInbound = hasSmtp; // IMAP requires EMAIL_USER + EMAIL_PASSWORD
34
+
35
+ if (!hasResend && !hasSmtp) {
36
+ console.log(`[Channel:Email] Skipped - set RESEND_API_KEY or EMAIL_USER+EMAIL_PASSWORD`);
23
37
  return;
24
38
  }
25
39
 
26
40
  const nodemailer = await import("nodemailer");
27
41
 
28
- // Set up SMTP transporter for sending replies
29
- this.transporter = nodemailer.default.createTransport({
30
- host: this.config.smtp.host,
31
- port: this.config.smtp.port,
32
- secure: this.config.smtp.port === 465,
33
- auth: {
34
- user: this.config.user,
35
- pass: this.config.password,
36
- },
37
- });
38
-
39
- // Start polling for new emails
40
- this.running = true;
41
- this.pollTimer = setInterval(() => this.pollEmails(), 60000); // every 60s
42
- console.log(`[Channel:Email] Started (polling every 60s, user: ${this.config.user})`);
42
+ // ── Outbound transport ────────────────────────────────────────────────────
43
+ if (hasResend) {
44
+ // Resend SMTP relay - no extra package needed, just nodemailer
45
+ this.transporter = nodemailer.default.createTransport({
46
+ host: "smtp.resend.com",
47
+ port: 465,
48
+ secure: true,
49
+ auth: { user: "resend", pass: this.config.resendApiKey },
50
+ });
51
+ this.fromAddress = this.config.resendFrom || `daemora@resend.dev`;
52
+ console.log(`[Channel:Email] Outbound: Resend (from: ${this.fromAddress})`);
53
+ } else {
54
+ // Traditional SMTP (Gmail, etc.)
55
+ this.transporter = nodemailer.default.createTransport({
56
+ host: this.config.smtp.host,
57
+ port: this.config.smtp.port,
58
+ secure: this.config.smtp.port === 465,
59
+ auth: { user: this.config.user, pass: this.config.password },
60
+ });
61
+ this.fromAddress = this.config.user;
62
+ console.log(`[Channel:Email] Outbound: SMTP (${this.config.smtp.host})`);
63
+ }
43
64
 
44
- // Poll immediately on start
45
- this.pollEmails();
65
+ // ── Inbound polling (IMAP) ────────────────────────────────────────────────
66
+ if (hasInbound) {
67
+ this.running = true;
68
+ this.pollTimer = setInterval(() => this.pollEmails(), 60000);
69
+ console.log(`[Channel:Email] Inbound: IMAP polling every 60s (${this.config.user})`);
70
+ this.pollEmails();
71
+ } else {
72
+ console.log(`[Channel:Email] Inbound: disabled (set EMAIL_USER+EMAIL_PASSWORD to enable IMAP polling)`);
73
+ }
46
74
  }
47
75
 
48
76
  async stop() {
@@ -124,7 +152,7 @@ export class EmailChannel extends BaseChannel {
124
152
  const emailMatch = from.match(/<([^>]+)>/);
125
153
  const senderEmail = emailMatch ? emailMatch[1] : from;
126
154
 
127
- // Extract body (simplified takes text after headers)
155
+ // Extract body (simplified - takes text after headers)
128
156
  const bodyStart = rawEmail.indexOf("\r\n\r\n");
129
157
  const emailBody = bodyStart > -1 ? rawEmail.slice(bodyStart + 4).trim() : rawEmail;
130
158
 
@@ -161,7 +189,7 @@ export class EmailChannel extends BaseChannel {
161
189
  if (!this.transporter) return;
162
190
 
163
191
  await this.transporter.sendMail({
164
- from: this.config.user,
192
+ from: this.fromAddress,
165
193
  to: channelMeta.senderEmail,
166
194
  subject: `Re: ${channelMeta.subject}`,
167
195
  text: text,
@@ -0,0 +1,140 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+
4
+ /**
5
+ * Feishu / Lark Channel - receives events via Feishu Event API (webhook mode).
6
+ *
7
+ * Setup:
8
+ * 1. Create a Feishu app at https://open.feishu.cn/app
9
+ * 2. Enable "Bot" capability and add "im:message:receive_v1" event subscription
10
+ * 3. Set the webhook URL to: https://your-domain.com/channels/feishu
11
+ * 4. Set env: FEISHU_APP_ID, FEISHU_APP_SECRET, FEISHU_VERIFICATION_TOKEN
12
+ *
13
+ * Config:
14
+ * appId - Feishu App ID
15
+ * appSecret - Feishu App Secret
16
+ * verificationToken - Event verification token
17
+ * port - Webhook port (default 3004)
18
+ * allowlist - Optional array of Feishu open_id values
19
+ * model - Optional model override
20
+ */
21
+ export class FeishuChannel extends BaseChannel {
22
+ constructor(config) {
23
+ super("feishu", config);
24
+ this.server = null;
25
+ this.accessToken = null;
26
+ this.tokenExpiry = 0;
27
+ }
28
+
29
+ async start() {
30
+ if (!this.config.appId || !this.config.appSecret) {
31
+ console.log("[Channel:Feishu] Skipped - missing FEISHU_APP_ID or FEISHU_APP_SECRET");
32
+ return;
33
+ }
34
+
35
+ const { createServer } = await import("node:http");
36
+
37
+ this.server = createServer(async (req, res) => {
38
+ if (req.method !== "POST" || req.url !== "/channels/feishu") {
39
+ res.writeHead(404).end();
40
+ return;
41
+ }
42
+
43
+ let body = "";
44
+ req.on("data", d => body += d);
45
+ req.on("end", async () => {
46
+ try {
47
+ const payload = JSON.parse(body);
48
+
49
+ // URL verification challenge
50
+ if (payload.type === "url_verification") {
51
+ res.writeHead(200, { "Content-Type": "application/json" });
52
+ res.end(JSON.stringify({ challenge: payload.challenge }));
53
+ return;
54
+ }
55
+
56
+ res.writeHead(200).end("ok");
57
+
58
+ const event = payload.event;
59
+ if (payload.header?.event_type !== "im.message.receive_v1") return;
60
+
61
+ const msg = event.message;
62
+ if (msg.message_type !== "text") return;
63
+
64
+ const senderId = event.sender?.sender_id?.open_id;
65
+ if (!senderId || !this.isAllowed(senderId)) return;
66
+
67
+ const content = JSON.parse(msg.content || "{}");
68
+ const input = content.text?.trim();
69
+ if (!input) return;
70
+
71
+ const channelMeta = {
72
+ chatId: event.message.chat_id,
73
+ senderId,
74
+ messageId: msg.message_id,
75
+ };
76
+
77
+ const task = await taskQueue.enqueue({
78
+ input,
79
+ channel: "feishu",
80
+ sessionId: this.getSessionId(senderId),
81
+ channelMeta,
82
+ model: this.getModel(),
83
+ });
84
+
85
+ const result = await taskQueue.waitForResult(task.id);
86
+ if (!this.isTaskMerged(result)) {
87
+ await this.sendReply(channelMeta, result.result || "(no response)");
88
+ }
89
+ } catch (err) {
90
+ console.log(`[Channel:Feishu] Error processing event: ${err.message}`);
91
+ }
92
+ });
93
+ });
94
+
95
+ const port = this.config.port || 3004;
96
+ await new Promise(resolve => this.server.listen(port, resolve));
97
+ this.running = true;
98
+ console.log(`[Channel:Feishu] Webhook listening on port ${port}/channels/feishu`);
99
+ }
100
+
101
+ async _getAccessToken() {
102
+ if (this.accessToken && Date.now() < this.tokenExpiry) return this.accessToken;
103
+ const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
104
+ const res = await fetchFn("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
105
+ method: "POST",
106
+ headers: { "Content-Type": "application/json" },
107
+ body: JSON.stringify({ app_id: this.config.appId, app_secret: this.config.appSecret }),
108
+ });
109
+ const data = await res.json();
110
+ this.accessToken = data.tenant_access_token;
111
+ this.tokenExpiry = Date.now() + (data.expire - 60) * 1000;
112
+ return this.accessToken;
113
+ }
114
+
115
+ async stop() {
116
+ if (this.server) {
117
+ await new Promise(resolve => this.server.close(resolve));
118
+ this.running = false;
119
+ }
120
+ console.log("[Channel:Feishu] Stopped");
121
+ }
122
+
123
+ async sendReply(channelMeta, text) {
124
+ if (!channelMeta?.chatId) return;
125
+ const token = await this._getAccessToken();
126
+ const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
127
+ await fetchFn("https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", {
128
+ method: "POST",
129
+ headers: {
130
+ "Authorization": `Bearer ${token}`,
131
+ "Content-Type": "application/json",
132
+ },
133
+ body: JSON.stringify({
134
+ receive_id: channelMeta.chatId,
135
+ msg_type: "text",
136
+ content: JSON.stringify({ text }),
137
+ }),
138
+ });
139
+ }
140
+ }
@@ -6,7 +6,7 @@ import { join, extname } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
 
8
8
  /**
9
- * Google Chat Channel receives messages via Chat App webhook.
9
+ * Google Chat Channel - receives messages via Chat App webhook.
10
10
  *
11
11
  * Setup:
12
12
  * 1. Go to https://console.cloud.google.com → New project → Enable "Google Chat API"
@@ -19,10 +19,10 @@ import { tmpdir } from "node:os";
19
19
  * 4. Set GOOGLE_CHAT_PROJECT_NUMBER (from Google Cloud project settings)
20
20
  *
21
21
  * Config:
22
- * serviceAccount JSON string of service account key (GOOGLE_CHAT_SERVICE_ACCOUNT)
23
- * projectNumber Google Cloud project number (GOOGLE_CHAT_PROJECT_NUMBER)
24
- * allowlist Optional array of Google user IDs / emails allowed to use the bot
25
- * model Optional model override
22
+ * serviceAccount - JSON string of service account key (GOOGLE_CHAT_SERVICE_ACCOUNT)
23
+ * projectNumber - Google Cloud project number (GOOGLE_CHAT_PROJECT_NUMBER)
24
+ * allowlist - Optional array of Google user IDs / emails allowed to use the bot
25
+ * model - Optional model override
26
26
  *
27
27
  * Unlike OpenClaw's 1000+ LOC implementation with multi-account support,
28
28
  * streaming coalescing, and GraphQL-style actions, this keeps it simple:
@@ -36,7 +36,7 @@ export class GoogleChatChannel extends BaseChannel {
36
36
 
37
37
  async start() {
38
38
  if (!this.config.serviceAccount) {
39
- console.log(`[Channel:GoogleChat] Skipped set GOOGLE_CHAT_SERVICE_ACCOUNT`);
39
+ console.log(`[Channel:GoogleChat] Skipped - set GOOGLE_CHAT_SERVICE_ACCOUNT`);
40
40
  return;
41
41
  }
42
42
 
@@ -46,7 +46,7 @@ export class GoogleChatChannel extends BaseChannel {
46
46
  this.running = true;
47
47
  console.log(`[Channel:GoogleChat] Ready (webhook: POST /webhooks/googlechat)`);
48
48
  if (this.config.allowlist?.length) {
49
- console.log(`[Channel:GoogleChat] Allowlist active ${this.config.allowlist.length} authorized user(s)`);
49
+ console.log(`[Channel:GoogleChat] Allowlist active - ${this.config.allowlist.length} authorized user(s)`);
50
50
  }
51
51
  } catch (err) {
52
52
  console.log(`[Channel:GoogleChat] Failed to initialise auth: ${err.message}`);
@@ -130,7 +130,7 @@ export class GoogleChatChannel extends BaseChannel {
130
130
  const input = inputParts.join("\n");
131
131
  console.log(`[Channel:GoogleChat] Message from ${userName} (${userId}): "${input.slice(0, 80)}"`);
132
132
 
133
- // Enqueue and wait Google Chat allows up to 30s for synchronous reply
133
+ // Enqueue and wait - Google Chat allows up to 30s for synchronous reply
134
134
  const task = taskQueue.enqueue({
135
135
  input,
136
136
  channel: "googlechat",
@@ -1,7 +1,7 @@
1
1
  import { BaseChannel } from "./BaseChannel.js";
2
2
 
3
3
  /**
4
- * HTTP Channel already handled by Express routes in index.js.
4
+ * HTTP Channel - already handled by Express routes in index.js.
5
5
  * This class exists for registry consistency but delegates to existing routes.
6
6
  */
7
7
  export class HttpChannel extends BaseChannel {
@@ -20,7 +20,7 @@ export class HttpChannel extends BaseChannel {
20
20
  }
21
21
 
22
22
  async sendReply(channelMeta, text) {
23
- // HTTP is sync response sent directly in the route handler
23
+ // HTTP is sync - response sent directly in the route handler
24
24
  // No async reply needed
25
25
  }
26
26
  }
@@ -0,0 +1,144 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+ import { createConnection } from "node:net";
4
+ import { createInterface } from "node:readline";
5
+
6
+ /**
7
+ * IRC Channel - connects to an IRC server and responds to direct messages and !ask commands.
8
+ *
9
+ * Setup:
10
+ * 1. Set env: IRC_SERVER, IRC_PORT (default 6667), IRC_NICK, IRC_CHANNEL (optional)
11
+ * Optional: IRC_PASSWORD (NickServ), IRC_COMMAND_PREFIX (default "!ask")
12
+ *
13
+ * Config:
14
+ * server - IRC server hostname (e.g. irc.libera.chat)
15
+ * port - Port (default 6667, use 6697 for SSL)
16
+ * nick - Bot nickname
17
+ * channel - Channel to join (with #)
18
+ * password - NickServ password (optional)
19
+ * prefix - Command prefix (default "!ask")
20
+ * allowlist - Optional array of IRC nicks
21
+ * model - Optional model override
22
+ */
23
+ export class IRCChannel extends BaseChannel {
24
+ constructor(config) {
25
+ super("irc", config);
26
+ this.socket = null;
27
+ this.rl = null;
28
+ this.nick = config.nick || "daemora-bot";
29
+ this.server = config.server;
30
+ this.port = config.port || 6667;
31
+ this.ircChannel = config.channel;
32
+ this.prefix = config.prefix || "!ask";
33
+ }
34
+
35
+ async start() {
36
+ if (!this.server || !this.nick) {
37
+ console.log("[Channel:IRC] Skipped - missing IRC_SERVER or IRC_NICK");
38
+ return;
39
+ }
40
+
41
+ try {
42
+ this.socket = createConnection(this.port, this.server);
43
+ this.rl = createInterface({ input: this.socket });
44
+
45
+ this.socket.on("connect", () => {
46
+ if (this.config.password) {
47
+ this._send(`PASS ${this.config.password}`);
48
+ }
49
+ this._send(`NICK ${this.nick}`);
50
+ this._send(`USER ${this.nick} 0 * :Daemora Agent Bot`);
51
+ if (this.ircChannel) {
52
+ setTimeout(() => this._send(`JOIN ${this.ircChannel}`), 3000);
53
+ }
54
+ this.running = true;
55
+ console.log(`[Channel:IRC] Connected to ${this.server}:${this.port}`);
56
+ });
57
+
58
+ this.rl.on("line", async (line) => {
59
+ // PING/PONG keep-alive
60
+ if (line.startsWith("PING")) {
61
+ this._send(`PONG ${line.slice(5)}`);
62
+ return;
63
+ }
64
+
65
+ // Parse PRIVMSG: :nick!user@host PRIVMSG target :message
66
+ const match = line.match(/^:(\S+?)!(\S+)@\S+ PRIVMSG (\S+) :(.+)$/);
67
+ if (!match) return;
68
+
69
+ const [, senderNick, , target, message] = match;
70
+ if (senderNick.toLowerCase() === this.nick.toLowerCase()) return;
71
+
72
+ // Determine if it's a DM (target === our nick) or channel command
73
+ const isDM = target.toLowerCase() === this.nick.toLowerCase();
74
+ const hasPrefix = message.toLowerCase().startsWith(this.prefix.toLowerCase());
75
+ const mentioned = message.toLowerCase().includes(this.nick.toLowerCase());
76
+
77
+ if (!isDM && !hasPrefix && !mentioned) return;
78
+ if (!this.isAllowed(senderNick)) return;
79
+
80
+ const input = isDM
81
+ ? message.trim()
82
+ : message.replace(new RegExp(`^${this.prefix}\\s*`, "i"), "")
83
+ .replace(new RegExp(`${this.nick}[:,]?\\s*`, "ig"), "")
84
+ .trim();
85
+
86
+ if (!input) return;
87
+
88
+ const replyTarget = isDM ? senderNick : target;
89
+ const channelMeta = { target: replyTarget, nick: senderNick };
90
+
91
+ const task = await taskQueue.enqueue({
92
+ input,
93
+ channel: "irc",
94
+ sessionId: this.getSessionId(senderNick),
95
+ channelMeta,
96
+ model: this.getModel(),
97
+ });
98
+
99
+ const result = await taskQueue.waitForResult(task.id);
100
+ if (!this.isTaskMerged(result)) {
101
+ await this.sendReply(channelMeta, result.result || "(no response)");
102
+ }
103
+ });
104
+
105
+ this.socket.on("error", (err) => console.log(`[Channel:IRC] Error: ${err.message}`));
106
+ this.socket.on("close", () => {
107
+ this.running = false;
108
+ console.log("[Channel:IRC] Disconnected");
109
+ });
110
+ } catch (err) {
111
+ console.log(`[Channel:IRC] Failed to start: ${err.message}`);
112
+ }
113
+ }
114
+
115
+ _send(text) {
116
+ if (this.socket?.writable) {
117
+ this.socket.write(`${text}\r\n`);
118
+ }
119
+ }
120
+
121
+ async stop() {
122
+ if (this.socket) {
123
+ this._send("QUIT :Daemora shutting down");
124
+ this.socket.destroy();
125
+ this.running = false;
126
+ }
127
+ console.log("[Channel:IRC] Stopped");
128
+ }
129
+
130
+ async sendReply(channelMeta, text) {
131
+ if (!channelMeta?.target) return;
132
+ // Split long responses into multiple lines (IRC limit 512 bytes per message)
133
+ const lines = text.split("\n").flatMap(line =>
134
+ line.match(/.{1,400}/g) || [line]
135
+ );
136
+ for (const line of lines.slice(0, 20)) { // max 20 lines to avoid flood
137
+ this._send(`PRIVMSG ${channelMeta.target} :${line}`);
138
+ await new Promise(r => setTimeout(r, 100)); // anti-flood delay
139
+ }
140
+ if (lines.length > 20) {
141
+ this._send(`PRIVMSG ${channelMeta.target} :(${lines.length - 20} more lines truncated)`);
142
+ }
143
+ }
144
+ }