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.
- package/README.md +666 -0
- package/SOUL.md +104 -0
- package/config/hooks.json +14 -0
- package/config/mcp.json +145 -0
- package/package.json +86 -0
- package/skills/.gitkeep +0 -0
- package/skills/apple-notes.md +193 -0
- package/skills/apple-reminders.md +189 -0
- package/skills/camsnap.md +162 -0
- package/skills/coding.md +14 -0
- package/skills/documents.md +13 -0
- package/skills/email.md +13 -0
- package/skills/gif-search.md +196 -0
- package/skills/healthcheck.md +225 -0
- package/skills/image-gen.md +147 -0
- package/skills/model-usage.md +182 -0
- package/skills/obsidian.md +207 -0
- package/skills/pdf.md +211 -0
- package/skills/research.md +13 -0
- package/skills/skill-creator.md +142 -0
- package/skills/spotify.md +149 -0
- package/skills/summarize.md +230 -0
- package/skills/things.md +199 -0
- package/skills/tmux.md +204 -0
- package/skills/trello.md +183 -0
- package/skills/video-frames.md +202 -0
- package/skills/weather.md +127 -0
- package/src/a2a/A2AClient.js +136 -0
- package/src/a2a/A2AServer.js +316 -0
- package/src/a2a/AgentCard.js +79 -0
- package/src/agents/SubAgentManager.js +369 -0
- package/src/agents/Supervisor.js +192 -0
- package/src/channels/BaseChannel.js +104 -0
- package/src/channels/DiscordChannel.js +288 -0
- package/src/channels/EmailChannel.js +172 -0
- package/src/channels/GoogleChatChannel.js +316 -0
- package/src/channels/HttpChannel.js +26 -0
- package/src/channels/LineChannel.js +168 -0
- package/src/channels/SignalChannel.js +186 -0
- package/src/channels/SlackChannel.js +329 -0
- package/src/channels/TeamsChannel.js +272 -0
- package/src/channels/TelegramChannel.js +347 -0
- package/src/channels/WhatsAppChannel.js +219 -0
- package/src/channels/index.js +198 -0
- package/src/cli.js +1267 -0
- package/src/config/agentProfiles.js +120 -0
- package/src/config/channels.js +32 -0
- package/src/config/default.js +206 -0
- package/src/config/models.js +123 -0
- package/src/config/permissions.js +167 -0
- package/src/core/AgentLoop.js +446 -0
- package/src/core/Compaction.js +143 -0
- package/src/core/CostTracker.js +116 -0
- package/src/core/EventBus.js +46 -0
- package/src/core/Task.js +67 -0
- package/src/core/TaskQueue.js +206 -0
- package/src/core/TaskRunner.js +226 -0
- package/src/daemon/DaemonManager.js +301 -0
- package/src/hooks/HookRunner.js +230 -0
- package/src/index.js +482 -0
- package/src/mcp/MCPAgentRunner.js +112 -0
- package/src/mcp/MCPClient.js +186 -0
- package/src/mcp/MCPManager.js +412 -0
- package/src/models/ModelRouter.js +180 -0
- package/src/safety/AuditLog.js +135 -0
- package/src/safety/CircuitBreaker.js +126 -0
- package/src/safety/FilesystemGuard.js +169 -0
- package/src/safety/GitRollback.js +139 -0
- package/src/safety/HumanApproval.js +156 -0
- package/src/safety/InputSanitizer.js +72 -0
- package/src/safety/PermissionGuard.js +83 -0
- package/src/safety/Sandbox.js +70 -0
- package/src/safety/SecretScanner.js +100 -0
- package/src/safety/SecretVault.js +250 -0
- package/src/scheduler/Heartbeat.js +115 -0
- package/src/scheduler/Scheduler.js +228 -0
- package/src/services/models/outputSchema.js +15 -0
- package/src/services/openai.js +25 -0
- package/src/services/sessions.js +65 -0
- package/src/setup/theme.js +110 -0
- package/src/setup/wizard.js +788 -0
- package/src/skills/SkillLoader.js +168 -0
- package/src/storage/TaskStore.js +69 -0
- package/src/systemPrompt.js +526 -0
- package/src/tenants/TenantContext.js +19 -0
- package/src/tenants/TenantManager.js +379 -0
- package/src/tools/ToolRegistry.js +141 -0
- package/src/tools/applyPatch.js +144 -0
- package/src/tools/browserAutomation.js +223 -0
- package/src/tools/createDocument.js +265 -0
- package/src/tools/cronTool.js +105 -0
- package/src/tools/editFile.js +139 -0
- package/src/tools/executeCommand.js +123 -0
- package/src/tools/glob.js +67 -0
- package/src/tools/grep.js +121 -0
- package/src/tools/imageAnalysis.js +120 -0
- package/src/tools/index.js +173 -0
- package/src/tools/listDirectory.js +47 -0
- package/src/tools/manageAgents.js +47 -0
- package/src/tools/manageMCP.js +159 -0
- package/src/tools/memory.js +478 -0
- package/src/tools/messageChannel.js +45 -0
- package/src/tools/projectTracker.js +259 -0
- package/src/tools/readFile.js +52 -0
- package/src/tools/screenCapture.js +112 -0
- package/src/tools/searchContent.js +76 -0
- package/src/tools/searchFiles.js +75 -0
- package/src/tools/sendEmail.js +118 -0
- package/src/tools/sendFile.js +63 -0
- package/src/tools/textToSpeech.js +161 -0
- package/src/tools/transcribeAudio.js +82 -0
- package/src/tools/useMCP.js +29 -0
- package/src/tools/webFetch.js +150 -0
- package/src/tools/webSearch.js +134 -0
- 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
|
+
}
|