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,316 @@
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
+ * Google Chat Channel — receives messages via Chat App webhook.
10
+ *
11
+ * Setup:
12
+ * 1. Go to https://console.cloud.google.com → New project → Enable "Google Chat API"
13
+ * 2. Under "Google Chat API" → Configuration:
14
+ * - App name, description, avatar URL (any image)
15
+ * - Bot URL: https://your-server/webhooks/googlechat
16
+ * - Check "Receive 1:1 messages" and "Join spaces and group conversations"
17
+ * 3. Create a Service Account (IAM → Service Accounts → Create):
18
+ * - Download JSON key → copy contents into GOOGLE_CHAT_SERVICE_ACCOUNT env var
19
+ * 4. Set GOOGLE_CHAT_PROJECT_NUMBER (from Google Cloud project settings)
20
+ *
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
26
+ *
27
+ * Unlike OpenClaw's 1000+ LOC implementation with multi-account support,
28
+ * streaming coalescing, and GraphQL-style actions, this keeps it simple:
29
+ * single account, text + file attachments in, text + files out.
30
+ */
31
+ export class GoogleChatChannel extends BaseChannel {
32
+ constructor(config) {
33
+ super("googlechat", config);
34
+ this._authClient = null;
35
+ }
36
+
37
+ async start() {
38
+ if (!this.config.serviceAccount) {
39
+ console.log(`[Channel:GoogleChat] Skipped — set GOOGLE_CHAT_SERVICE_ACCOUNT`);
40
+ return;
41
+ }
42
+
43
+ // Verify auth client initialises without error
44
+ try {
45
+ this._authClient = await this._buildAuthClient();
46
+ this.running = true;
47
+ console.log(`[Channel:GoogleChat] Ready (webhook: POST /webhooks/googlechat)`);
48
+ if (this.config.allowlist?.length) {
49
+ console.log(`[Channel:GoogleChat] Allowlist active — ${this.config.allowlist.length} authorized user(s)`);
50
+ }
51
+ } catch (err) {
52
+ console.log(`[Channel:GoogleChat] Failed to initialise auth: ${err.message}`);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Handle inbound webhook from Google Chat.
58
+ * Called by Express route in index.js.
59
+ * Google Chat expects a JSON response within 30 seconds (synchronous mode).
60
+ */
61
+ async handleWebhook(req, res) {
62
+ // ── Verify the request came from Google Chat ──────────────────────────────
63
+ const valid = await this._verifyRequest(req);
64
+ if (!valid) {
65
+ res.status(401).json({ error: "Unauthorized" });
66
+ return;
67
+ }
68
+
69
+ const event = req.body;
70
+ const type = event.type;
71
+
72
+ // Bot added to a space
73
+ if (type === "ADDED_TO_SPACE") {
74
+ res.json({ text: "Hello! I'm Daemora. Message me and I'll get to work." });
75
+ return;
76
+ }
77
+
78
+ if (type !== "MESSAGE") {
79
+ res.json({});
80
+ return;
81
+ }
82
+
83
+ const message = event.message;
84
+ const sender = message?.sender;
85
+ const userId = sender?.name || sender?.email || "unknown"; // "users/12345..." format
86
+ const userName = sender?.displayName || "User";
87
+ const spaceName = message?.space?.name; // "spaces/AAAA..."
88
+ const msgName = message?.name; // "spaces/AAAA.../messages/BBB..." (for threading)
89
+
90
+ // Strip @bot mention from text
91
+ const text = (message?.text || "")
92
+ .replace(/<[^>]+>/g, "") // strip <mention> tags
93
+ .trim();
94
+
95
+ const attachments = message?.attachment || [];
96
+
97
+ // Allowlist check (match against userId or email)
98
+ const idToCheck = sender?.email || userId;
99
+ if (!this.isAllowed(idToCheck)) {
100
+ res.json({ text: "You are not authorized to use this agent." });
101
+ return;
102
+ }
103
+
104
+ // Build input from text + any media attachments
105
+ const inputParts = text ? [text] : [];
106
+ for (const att of attachments) {
107
+ if (!att.attachmentDataRef?.resourceName) continue;
108
+ const localPath = await this._downloadAttachment(att);
109
+ if (!localPath) continue;
110
+ const mimeType = att.contentType || "";
111
+ if (mimeType.startsWith("audio/")) {
112
+ const transcript = await transcribeAudio(localPath);
113
+ inputParts.push(transcript.startsWith("Error:")
114
+ ? `[Audio file: ${localPath}]\n${transcript}`
115
+ : `[Audio transcript]: ${transcript}`);
116
+ } else if (mimeType.startsWith("image/")) {
117
+ inputParts.push(`[Photo received: ${localPath}]${text ? "" : "\nDescribe this image."}`);
118
+ } else if (mimeType.startsWith("video/")) {
119
+ inputParts.push(`[Video received: ${localPath}]`);
120
+ } else {
121
+ inputParts.push(`[File received: ${localPath} (${att.contentName || "attachment"})]`);
122
+ }
123
+ }
124
+
125
+ if (inputParts.length === 0) {
126
+ res.json({ text: "Send me a message and I'll get to work." });
127
+ return;
128
+ }
129
+
130
+ const input = inputParts.join("\n");
131
+ console.log(`[Channel:GoogleChat] Message from ${userName} (${userId}): "${input.slice(0, 80)}"`);
132
+
133
+ // Enqueue and wait — Google Chat allows up to 30s for synchronous reply
134
+ const task = taskQueue.enqueue({
135
+ input,
136
+ channel: "googlechat",
137
+ channelMeta: { userId, userName, spaceName, msgName, channel: "googlechat" },
138
+ sessionId: this.getSessionId(userId),
139
+ model: this.getModel(),
140
+ });
141
+
142
+ try {
143
+ const completedTask = await taskQueue.waitForCompletion(task.id);
144
+ if (this.isTaskMerged(completedTask)) { res.json({ text: "" }); return; } // absorbed
145
+ const failed = completedTask.status === "failed";
146
+ const response = failed
147
+ ? `Sorry, I encountered an error: ${completedTask.error}`
148
+ : completedTask.result || "Done.";
149
+
150
+ // Google Chat has a 4000-char message limit; split into multiple API calls if needed
151
+ const chunks = splitMessage(response, 4000);
152
+ if (chunks.length === 1) {
153
+ res.json({ text: chunks[0] });
154
+ } else {
155
+ // First chunk via synchronous response
156
+ res.json({ text: chunks[0] });
157
+ // Remaining chunks sent asynchronously via Chat REST API
158
+ for (let i = 1; i < chunks.length; i++) {
159
+ this._sendMessage(spaceName, chunks[i], msgName).catch(() => {});
160
+ }
161
+ }
162
+ } catch (err) {
163
+ console.error(`[Channel:GoogleChat] Error: ${err.message}`);
164
+ res.json({ text: "Sorry, something went wrong. Please try again." });
165
+ }
166
+ }
167
+
168
+ async stop() {
169
+ this.running = false;
170
+ console.log(`[Channel:GoogleChat] Stopped`);
171
+ }
172
+
173
+ async sendReply(channelMeta, text) {
174
+ if (!channelMeta.spaceName) return;
175
+ const chunks = splitMessage(text, 4000);
176
+ for (const chunk of chunks) {
177
+ await this._sendMessage(channelMeta.spaceName, chunk).catch(() => {});
178
+ }
179
+ }
180
+
181
+ async sendFile(channelMeta, filePath, caption) {
182
+ // Google Chat file upload requires a multipart upload to the media endpoint.
183
+ // For simplicity: send caption + note about the file.
184
+ // Full file upload requires additional Google Chat API scope setup.
185
+ if (caption) await this.sendReply(channelMeta, caption);
186
+ await this.sendReply(channelMeta, `(File: ${filePath})`);
187
+ }
188
+
189
+ // ── Internal helpers ────────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Build a GoogleAuth client with the chat.bot scope.
193
+ */
194
+ async _buildAuthClient() {
195
+ const { GoogleAuth } = await import("google-auth-library");
196
+ const credentials = typeof this.config.serviceAccount === "string"
197
+ ? JSON.parse(this.config.serviceAccount)
198
+ : this.config.serviceAccount;
199
+
200
+ const auth = new GoogleAuth({
201
+ credentials,
202
+ scopes: ["https://www.googleapis.com/auth/chat.bot"],
203
+ });
204
+ return auth.getClient();
205
+ }
206
+
207
+ /**
208
+ * Get a fresh access token (google-auth-library caches and auto-refreshes it).
209
+ */
210
+ async _getAccessToken() {
211
+ if (!this._authClient) this._authClient = await this._buildAuthClient();
212
+ const token = await this._authClient.getAccessToken();
213
+ return token.token;
214
+ }
215
+
216
+ /**
217
+ * Verify that an inbound request actually comes from Google Chat.
218
+ * Google sends an OIDC Bearer token signed by chat@system.gserviceaccount.com.
219
+ * The audience is the PUBLIC_URL of the bot (or project number for simple mode).
220
+ */
221
+ async _verifyRequest(req) {
222
+ const authHeader = req.headers["authorization"] || "";
223
+ const token = authHeader.replace(/^Bearer\s+/i, "").trim();
224
+ if (!token) return false;
225
+
226
+ try {
227
+ const { OAuth2Client } = await import("google-auth-library");
228
+ const client = new OAuth2Client();
229
+ // Audience: your public bot URL. Fallback: accept any audience (less secure, good for dev)
230
+ const audience = process.env.PUBLIC_URL || undefined;
231
+ const ticket = await client.verifyIdToken({ idToken: token, audience });
232
+ const payload = ticket.getPayload();
233
+ // Must be issued by Google Chat service account
234
+ return payload?.email === "chat@system.gserviceaccount.com" || !audience;
235
+ } catch {
236
+ // In development without PUBLIC_URL, skip verification and trust the request
237
+ if (!process.env.PUBLIC_URL) {
238
+ console.log(`[Channel:GoogleChat] Warning: set PUBLIC_URL to enable request verification`);
239
+ return true;
240
+ }
241
+ return false;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Send a text message to a Google Chat space via REST API.
247
+ */
248
+ async _sendMessage(spaceName, text, threadName) {
249
+ const token = await this._getAccessToken();
250
+ const url = `https://chat.googleapis.com/v1/${spaceName}/messages`;
251
+ const body = { text };
252
+ if (threadName) {
253
+ body.thread = { name: threadName };
254
+ }
255
+ const res = await fetch(url, {
256
+ method: "POST",
257
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
258
+ body: JSON.stringify(body),
259
+ signal: AbortSignal.timeout(15000),
260
+ });
261
+ if (!res.ok) console.log(`[Channel:GoogleChat] sendMessage error: HTTP ${res.status}`);
262
+ }
263
+
264
+ /**
265
+ * Download a Google Chat attachment to /tmp.
266
+ * Uses the attachmentDataRef.resourceName with the chat.bot access token.
267
+ */
268
+ async _downloadAttachment(att) {
269
+ try {
270
+ const resourceName = att.attachmentDataRef?.resourceName;
271
+ if (!resourceName) return null;
272
+
273
+ const token = await this._getAccessToken();
274
+ const url = `https://chat.googleapis.com/v1/${resourceName}?alt=media`;
275
+ const res = await fetch(url, {
276
+ headers: { Authorization: `Bearer ${token}` },
277
+ signal: AbortSignal.timeout(30000),
278
+ });
279
+ if (!res.ok) return null;
280
+
281
+ const ext = _extFromMime(att.contentType || "") || extname(att.contentName || "");
282
+ const tmpDir = join(tmpdir(), "daemora-googlechat");
283
+ mkdirSync(tmpDir, { recursive: true });
284
+ const filePath = join(tmpDir, `att-${Date.now()}${ext}`);
285
+ writeFileSync(filePath, Buffer.from(await res.arrayBuffer()));
286
+ return filePath;
287
+ } catch (err) {
288
+ console.log(`[Channel:GoogleChat] Attachment download error: ${err.message}`);
289
+ return null;
290
+ }
291
+ }
292
+ }
293
+
294
+ function _extFromMime(mime) {
295
+ const map = {
296
+ "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp",
297
+ "audio/ogg": ".ogg", "audio/mpeg": ".mp3", "audio/mp4": ".m4a", "audio/wav": ".wav",
298
+ "video/mp4": ".mp4", "application/pdf": ".pdf",
299
+ };
300
+ return map[mime] || "";
301
+ }
302
+
303
+ function splitMessage(text, maxLength) {
304
+ if (text.length <= maxLength) return [text];
305
+ const chunks = [];
306
+ let remaining = text;
307
+ while (remaining.length > 0) {
308
+ if (remaining.length <= maxLength) { chunks.push(remaining); break; }
309
+ let idx = remaining.lastIndexOf("\n", maxLength);
310
+ if (idx < maxLength * 0.5) idx = remaining.lastIndexOf(" ", maxLength);
311
+ if (idx === -1) idx = maxLength;
312
+ chunks.push(remaining.slice(0, idx));
313
+ remaining = remaining.slice(idx).trimStart();
314
+ }
315
+ return chunks;
316
+ }
@@ -0,0 +1,26 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+
3
+ /**
4
+ * HTTP Channel — already handled by Express routes in index.js.
5
+ * This class exists for registry consistency but delegates to existing routes.
6
+ */
7
+ export class HttpChannel extends BaseChannel {
8
+ constructor(config) {
9
+ super("http", config);
10
+ }
11
+
12
+ async start() {
13
+ // HTTP routes are set up in index.js directly
14
+ this.running = true;
15
+ console.log(`[Channel:HTTP] Active (routes handled by Express)`);
16
+ }
17
+
18
+ async stop() {
19
+ this.running = false;
20
+ }
21
+
22
+ async sendReply(channelMeta, text) {
23
+ // HTTP is sync — response sent directly in the route handler
24
+ // No async reply needed
25
+ }
26
+ }
@@ -0,0 +1,168 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+ import crypto from "crypto";
4
+
5
+ /**
6
+ * LINE Channel — receives messages via LINE Messaging API webhook.
7
+ *
8
+ * Setup:
9
+ * 1. Go to https://developers.line.biz → Create a provider → Create a Messaging API channel
10
+ * 2. Under "Messaging API" → Issue a channel access token (long-lived) → LINE_CHANNEL_ACCESS_TOKEN
11
+ * 3. Under "Basic settings" → Channel secret → LINE_CHANNEL_SECRET
12
+ * 4. Set webhook URL to: https://your-server/webhooks/line
13
+ * (requires a public HTTPS URL — use ngrok or deploy to a server)
14
+ * 5. Enable "Use webhook" → Verify
15
+ * 6. Set env: LINE_CHANNEL_SECRET, LINE_CHANNEL_ACCESS_TOKEN
16
+ *
17
+ * Config:
18
+ * accessToken — Channel access token
19
+ * channelSecret — Channel secret for HMAC signature validation
20
+ * allowlist — Optional array of LINE user IDs (Uxxxxxxxx) allowed to use the bot
21
+ * model — Optional model override
22
+ *
23
+ * The bot responds to all direct messages sent to the LINE Official Account.
24
+ */
25
+ export class LineChannel extends BaseChannel {
26
+ constructor(config) {
27
+ super("line", config);
28
+ this.accessToken = config.accessToken;
29
+ this.channelSecret = config.channelSecret;
30
+ }
31
+
32
+ async start() {
33
+ if (!this.accessToken || !this.channelSecret) {
34
+ console.log(`[Channel:LINE] Skipped — need LINE_CHANNEL_ACCESS_TOKEN and LINE_CHANNEL_SECRET`);
35
+ return;
36
+ }
37
+
38
+ this.running = true;
39
+ console.log(`[Channel:LINE] Ready (webhook: POST /webhooks/line)`);
40
+ if (this.config.allowlist?.length) {
41
+ console.log(`[Channel:LINE] Allowlist active — ${this.config.allowlist.length} authorized user(s)`);
42
+ }
43
+ }
44
+
45
+ async stop() {
46
+ this.running = false;
47
+ console.log(`[Channel:LINE] Stopped`);
48
+ }
49
+
50
+ /**
51
+ * Validate LINE webhook signature.
52
+ * LINE signs each request with HMAC-SHA256 using the channel secret.
53
+ */
54
+ validateSignature(rawBody, signature) {
55
+ const expected = crypto
56
+ .createHmac("sha256", this.channelSecret)
57
+ .update(rawBody)
58
+ .digest("base64");
59
+ return signature === expected;
60
+ }
61
+
62
+ /**
63
+ * Handle incoming webhook from LINE.
64
+ * Called from Express route in index.js — passed the validated request body.
65
+ */
66
+ async handleWebhook(rawBody, signature, body) {
67
+ // Signature validation — reject unsigned requests
68
+ if (!this.validateSignature(rawBody, signature)) {
69
+ console.log(`[Channel:LINE] Invalid signature — request rejected`);
70
+ return { error: "Invalid signature" };
71
+ }
72
+
73
+ const events = body.events || [];
74
+
75
+ for (const event of events) {
76
+ if (event.type !== "message" || event.message?.type !== "text") continue;
77
+
78
+ const text = event.message.text?.trim();
79
+ const replyToken = event.replyToken;
80
+ const userId = event.source?.userId;
81
+
82
+ if (!text || !replyToken) continue;
83
+
84
+ // Allowlist check
85
+ if (!this.isAllowed(userId)) {
86
+ console.log(`[Channel:LINE] Blocked (not in allowlist): ${userId}`);
87
+ await this._sendPush(userId, "You are not authorized to use this agent.");
88
+ continue;
89
+ }
90
+
91
+ console.log(`[Channel:LINE] Message from ${userId}: "${text.slice(0, 80)}"`);
92
+
93
+ const task = taskQueue.enqueue({
94
+ input: text,
95
+ channel: "line",
96
+ channelMeta: { userId, replyToken, channel: "line" },
97
+ sessionId: this.getSessionId(userId),
98
+ model: this.getModel(),
99
+ });
100
+
101
+ // LINE reply tokens expire quickly — process in background and use push message
102
+ taskQueue.waitForCompletion(task.id).then(async (completedTask) => {
103
+ if (this.isTaskMerged(completedTask)) return; // absorbed into concurrent session
104
+ const response = completedTask.status === "failed"
105
+ ? `Sorry, I encountered an error: ${completedTask.error}`
106
+ : completedTask.result || "Done.";
107
+
108
+ // Use push message (not reply) since reply tokens expire fast
109
+ await this.sendReply({ userId }, response);
110
+ }).catch((err) => {
111
+ console.error(`[Channel:LINE] Task error: ${err.message}`);
112
+ });
113
+ }
114
+
115
+ return { ok: true };
116
+ }
117
+
118
+ /**
119
+ * Send a message to a LINE user via push message API.
120
+ * Push messages work without a reply token — needed for long-running tasks.
121
+ */
122
+ async sendReply(channelMeta, text) {
123
+ if (!this.accessToken || !channelMeta.userId) return;
124
+ await this._sendPush(channelMeta.userId, text);
125
+ }
126
+
127
+ async _sendPush(userId, text) {
128
+ // LINE text message limit: 5000 chars
129
+ const chunks = splitMessage(text, 4990);
130
+ const messages = chunks.map((chunk) => ({ type: "text", text: chunk }));
131
+
132
+ try {
133
+ const res = await fetch("https://api.line.me/v2/bot/message/push", {
134
+ method: "POST",
135
+ headers: {
136
+ "Content-Type": "application/json",
137
+ "Authorization": `Bearer ${this.accessToken}`,
138
+ },
139
+ body: JSON.stringify({
140
+ to: userId,
141
+ messages: messages.slice(0, 5), // LINE allows max 5 messages per push
142
+ }),
143
+ });
144
+
145
+ if (!res.ok) {
146
+ const err = await res.text();
147
+ console.log(`[Channel:LINE] Push message failed: ${err}`);
148
+ }
149
+ } catch (err) {
150
+ console.log(`[Channel:LINE] sendReply error: ${err.message}`);
151
+ }
152
+ }
153
+ }
154
+
155
+ function splitMessage(text, maxLength) {
156
+ if (text.length <= maxLength) return [text];
157
+ const chunks = [];
158
+ let remaining = text;
159
+ while (remaining.length > 0) {
160
+ if (remaining.length <= maxLength) { chunks.push(remaining); break; }
161
+ let idx = remaining.lastIndexOf("\n", maxLength);
162
+ if (idx < maxLength * 0.5) idx = remaining.lastIndexOf(" ", maxLength);
163
+ if (idx === -1) idx = maxLength;
164
+ chunks.push(remaining.slice(0, idx));
165
+ remaining = remaining.slice(idx).trimStart();
166
+ }
167
+ return chunks;
168
+ }
@@ -0,0 +1,186 @@
1
+ import { BaseChannel } from "./BaseChannel.js";
2
+ import taskQueue from "../core/TaskQueue.js";
3
+
4
+ /**
5
+ * Signal Channel — receives messages via signal-cli REST API.
6
+ *
7
+ * signal-cli is an open-source CLI tool that acts as a Signal client.
8
+ * It must be running separately as a daemon before Daemora starts.
9
+ *
10
+ * Setup:
11
+ * 1. Install signal-cli: https://github.com/AsamK/signal-cli
12
+ * 2. Register your phone number:
13
+ * signal-cli -u +1234567890 register
14
+ * signal-cli -u +1234567890 verify CODE
15
+ * 3. Start signal-cli as a REST daemon:
16
+ * signal-cli -u +1234567890 daemon --http 127.0.0.1:8080
17
+ * 4. Set env:
18
+ * SIGNAL_CLI_URL=http://127.0.0.1:8080 (signal-cli REST URL)
19
+ * SIGNAL_PHONE_NUMBER=+1234567890 (your registered number)
20
+ *
21
+ * Config:
22
+ * cliUrl — signal-cli REST URL
23
+ * phoneNumber — your registered Signal number
24
+ * allowlist — Optional array of phone numbers (+1234567890) allowed to send tasks
25
+ * model — Optional model override
26
+ *
27
+ * Daemora polls signal-cli every 2 seconds for new messages.
28
+ * All replies are sent back through signal-cli.
29
+ */
30
+ export class SignalChannel extends BaseChannel {
31
+ constructor(config) {
32
+ super("signal", config);
33
+ this.cliUrl = config.cliUrl;
34
+ this.phoneNumber = config.phoneNumber;
35
+ this.pollInterval = null;
36
+ this.processing = new Set(); // Track in-flight messages to avoid duplicates
37
+ }
38
+
39
+ async start() {
40
+ if (!this.cliUrl || !this.phoneNumber) {
41
+ console.log(`[Channel:Signal] Skipped — need SIGNAL_CLI_URL and SIGNAL_PHONE_NUMBER`);
42
+ return;
43
+ }
44
+
45
+ // Test connectivity to signal-cli
46
+ try {
47
+ const res = await fetch(`${this.cliUrl}/v1/health`);
48
+ if (!res.ok) throw new Error(`signal-cli returned ${res.status}`);
49
+ } catch (err) {
50
+ console.log(`[Channel:Signal] Cannot reach signal-cli at ${this.cliUrl}: ${err.message}`);
51
+ console.log(`[Channel:Signal] Start signal-cli daemon: signal-cli -u ${this.phoneNumber} daemon --http 127.0.0.1:8080`);
52
+ return;
53
+ }
54
+
55
+ this.running = true;
56
+ console.log(`[Channel:Signal] Started — polling ${this.cliUrl} for ${this.phoneNumber}`);
57
+ if (this.config.allowlist?.length) {
58
+ console.log(`[Channel:Signal] Allowlist active — ${this.config.allowlist.length} authorized number(s)`);
59
+ }
60
+
61
+ // Poll for new messages every 2 seconds
62
+ this.pollInterval = setInterval(() => this._poll(), 2000);
63
+ }
64
+
65
+ async _poll() {
66
+ try {
67
+ const res = await fetch(
68
+ `${this.cliUrl}/v1/receive/${encodeURIComponent(this.phoneNumber)}`,
69
+ { signal: AbortSignal.timeout(5000) }
70
+ );
71
+
72
+ if (!res.ok) return;
73
+
74
+ const messages = await res.json();
75
+ if (!Array.isArray(messages) || messages.length === 0) return;
76
+
77
+ for (const envelope of messages) {
78
+ await this._handleEnvelope(envelope);
79
+ }
80
+ } catch (_) {
81
+ // Silent — network errors during polling are expected if signal-cli restarts
82
+ }
83
+ }
84
+
85
+ async _handleEnvelope(envelope) {
86
+ const dataMessage = envelope?.envelope?.dataMessage;
87
+ if (!dataMessage) return;
88
+
89
+ const text = dataMessage.message?.trim();
90
+ const sender = envelope?.envelope?.source;
91
+ const timestamp = dataMessage.timestamp;
92
+
93
+ if (!text || !sender) return;
94
+
95
+ // Deduplicate by timestamp+sender
96
+ const key = `${sender}:${timestamp}`;
97
+ if (this.processing.has(key)) return;
98
+ this.processing.add(key);
99
+
100
+ // Clean up old keys after 30 seconds
101
+ setTimeout(() => this.processing.delete(key), 30_000);
102
+
103
+ // Allowlist check
104
+ if (!this.isAllowed(sender)) {
105
+ console.log(`[Channel:Signal] Blocked (not in allowlist): ${sender}`);
106
+ await this.sendReply({ sender }, "You are not authorized to use this agent.");
107
+ return;
108
+ }
109
+
110
+ console.log(`[Channel:Signal] Message from ${sender}: "${text.slice(0, 80)}"`);
111
+
112
+ const task = taskQueue.enqueue({
113
+ input: text,
114
+ channel: "signal",
115
+ channelMeta: { sender, channel: "signal" },
116
+ sessionId: this.getSessionId(sender),
117
+ model: this.getModel(),
118
+ });
119
+
120
+ try {
121
+ const completedTask = await taskQueue.waitForCompletion(task.id);
122
+ if (this.isTaskMerged(completedTask)) return; // absorbed into concurrent session
123
+ const response = completedTask.status === "failed"
124
+ ? `Error: ${completedTask.error}`
125
+ : completedTask.result || "Done.";
126
+
127
+ await this.sendReply({ sender }, response);
128
+ } catch (err) {
129
+ console.error(`[Channel:Signal] Task error: ${err.message}`);
130
+ }
131
+ }
132
+
133
+ async stop() {
134
+ if (this.pollInterval) {
135
+ clearInterval(this.pollInterval);
136
+ this.pollInterval = null;
137
+ }
138
+ this.running = false;
139
+ console.log(`[Channel:Signal] Stopped`);
140
+ }
141
+
142
+ async sendReply(channelMeta, text) {
143
+ if (!this.running || !channelMeta.sender) return;
144
+
145
+ // Signal message limit is ~64KB but keep practical
146
+ const chunks = splitMessage(text, 3000);
147
+
148
+ for (const chunk of chunks) {
149
+ try {
150
+ const res = await fetch(
151
+ `${this.cliUrl}/v2/send`,
152
+ {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json" },
155
+ body: JSON.stringify({
156
+ number: this.phoneNumber,
157
+ recipients: [channelMeta.sender],
158
+ message: chunk,
159
+ }),
160
+ }
161
+ );
162
+ if (!res.ok) {
163
+ const err = await res.text();
164
+ console.log(`[Channel:Signal] Send failed: ${err}`);
165
+ }
166
+ } catch (err) {
167
+ console.log(`[Channel:Signal] sendReply error: ${err.message}`);
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ function splitMessage(text, maxLength) {
174
+ if (text.length <= maxLength) return [text];
175
+ const chunks = [];
176
+ let remaining = text;
177
+ while (remaining.length > 0) {
178
+ if (remaining.length <= maxLength) { chunks.push(remaining); break; }
179
+ let idx = remaining.lastIndexOf("\n", maxLength);
180
+ if (idx < maxLength * 0.5) idx = remaining.lastIndexOf(" ", maxLength);
181
+ if (idx === -1) idx = maxLength;
182
+ chunks.push(remaining.slice(0, idx));
183
+ remaining = remaining.slice(idx).trimStart();
184
+ }
185
+ return chunks;
186
+ }