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.
- package/README.md +106 -76
- package/SOUL.md +100 -28
- package/config/mcp.json +9 -9
- package/package.json +15 -8
- package/skills/apple-notes.md +0 -52
- package/skills/apple-reminders.md +1 -87
- package/skills/camsnap.md +20 -144
- package/skills/coding.md +7 -7
- package/skills/documents.md +6 -6
- package/skills/email.md +6 -6
- package/skills/gif-search.md +28 -171
- package/skills/healthcheck.md +21 -203
- package/skills/image-gen.md +24 -123
- package/skills/model-usage.md +18 -165
- package/skills/obsidian.md +28 -174
- package/skills/pdf.md +30 -181
- package/skills/research.md +6 -6
- package/skills/skill-creator.md +35 -111
- package/skills/spotify.md +2 -17
- package/skills/summarize.md +36 -193
- package/skills/things.md +23 -175
- package/skills/tmux.md +1 -91
- package/skills/trello.md +32 -157
- package/skills/video-frames.md +26 -166
- package/skills/weather.md +6 -6
- package/src/a2a/A2AClient.js +2 -2
- package/src/a2a/A2AServer.js +6 -6
- package/src/a2a/AgentCard.js +2 -2
- package/src/agents/SubAgentManager.js +61 -19
- package/src/agents/Supervisor.js +4 -4
- package/src/channels/BaseChannel.js +6 -6
- package/src/channels/BlueBubblesChannel.js +112 -0
- package/src/channels/DiscordChannel.js +8 -8
- package/src/channels/EmailChannel.js +54 -26
- package/src/channels/FeishuChannel.js +140 -0
- package/src/channels/GoogleChatChannel.js +8 -8
- package/src/channels/HttpChannel.js +2 -2
- package/src/channels/IRCChannel.js +144 -0
- package/src/channels/LineChannel.js +13 -13
- package/src/channels/MatrixChannel.js +97 -0
- package/src/channels/MattermostChannel.js +119 -0
- package/src/channels/NextcloudChannel.js +133 -0
- package/src/channels/NostrChannel.js +175 -0
- package/src/channels/SignalChannel.js +9 -9
- package/src/channels/SlackChannel.js +10 -10
- package/src/channels/TeamsChannel.js +10 -10
- package/src/channels/TelegramChannel.js +8 -8
- package/src/channels/TwitchChannel.js +128 -0
- package/src/channels/WhatsAppChannel.js +10 -10
- package/src/channels/ZaloChannel.js +119 -0
- package/src/channels/iMessageChannel.js +150 -0
- package/src/channels/index.js +241 -11
- package/src/cli.js +834 -37
- package/src/config/agentProfiles.js +19 -19
- package/src/config/channels.js +1 -1
- package/src/config/default.js +12 -7
- package/src/config/models.js +13 -3
- package/src/config/permissions.js +2 -2
- package/src/core/AgentLoop.js +13 -13
- package/src/core/Compaction.js +3 -3
- package/src/core/CostTracker.js +2 -2
- package/src/core/EventBus.js +15 -15
- package/src/core/TaskQueue.js +24 -7
- package/src/core/TaskRunner.js +19 -6
- package/src/daemon/DaemonManager.js +4 -4
- package/src/hooks/HookRunner.js +4 -4
- package/src/index.js +6 -2
- package/src/mcp/MCPAgentRunner.js +3 -3
- package/src/mcp/MCPClient.js +9 -9
- package/src/mcp/MCPManager.js +14 -14
- package/src/models/ModelRouter.js +2 -2
- package/src/safety/AuditLog.js +3 -3
- package/src/safety/CircuitBreaker.js +2 -2
- package/src/safety/CommandGuard.js +132 -0
- package/src/safety/FilesystemGuard.js +23 -3
- package/src/safety/GitRollback.js +5 -5
- package/src/safety/HumanApproval.js +9 -9
- package/src/safety/InputSanitizer.js +81 -8
- package/src/safety/PermissionGuard.js +2 -2
- package/src/safety/Sandbox.js +1 -1
- package/src/safety/SecretScanner.js +90 -28
- package/src/safety/SecretVault.js +2 -2
- package/src/scheduler/Heartbeat.js +3 -3
- package/src/scheduler/Scheduler.js +6 -6
- package/src/setup/theme.js +171 -66
- package/src/setup/wizard.js +432 -57
- package/src/skills/SkillLoader.js +145 -8
- package/src/storage/TaskStore.js +39 -15
- package/src/systemPrompt.js +45 -43
- package/src/tenants/TenantManager.js +79 -22
- package/src/tools/ToolRegistry.js +3 -3
- package/src/tools/applyPatch.js +2 -2
- package/src/tools/browserAutomation.js +4 -4
- package/src/tools/calendar.js +155 -0
- package/src/tools/clipboard.js +71 -0
- package/src/tools/contacts.js +138 -0
- package/src/tools/createDocument.js +2 -2
- package/src/tools/cronTool.js +14 -14
- package/src/tools/database.js +165 -0
- package/src/tools/editFile.js +10 -10
- package/src/tools/executeCommand.js +11 -3
- package/src/tools/generateImage.js +79 -0
- package/src/tools/gitTool.js +141 -0
- package/src/tools/glob.js +1 -1
- package/src/tools/googlePlaces.js +136 -0
- package/src/tools/grep.js +2 -2
- package/src/tools/iMessageTool.js +86 -0
- package/src/tools/imageAnalysis.js +3 -3
- package/src/tools/index.js +56 -2
- package/src/tools/makeVoiceCall.js +283 -0
- package/src/tools/manageAgents.js +2 -2
- package/src/tools/manageMCP.js +38 -20
- package/src/tools/memory.js +25 -32
- package/src/tools/messageChannel.js +1 -1
- package/src/tools/notification.js +90 -0
- package/src/tools/philipsHue.js +147 -0
- package/src/tools/projectTracker.js +8 -8
- package/src/tools/readFile.js +1 -1
- package/src/tools/readPDF.js +73 -0
- package/src/tools/screenCapture.js +6 -6
- package/src/tools/searchContent.js +2 -2
- package/src/tools/searchFiles.js +1 -1
- package/src/tools/sendEmail.js +79 -24
- package/src/tools/sendFile.js +4 -4
- package/src/tools/sonos.js +137 -0
- package/src/tools/sshTool.js +130 -0
- package/src/tools/textToSpeech.js +5 -5
- package/src/tools/transcribeAudio.js +4 -4
- package/src/tools/useMCP.js +4 -4
- package/src/tools/webFetch.js +2 -2
- package/src/tools/webSearch.js +1 -1
- package/src/utils/Embeddings.js +79 -0
- package/src/voice/VoiceSessionManager.js +170 -0
- 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
|
|
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
|
|
21
|
-
* allowlist
|
|
22
|
-
* model
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
5
|
+
* Email Channel - polls IMAP for incoming emails, replies via SMTP or Resend.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
pass: this.config.
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
//
|
|
45
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
23
|
-
* projectNumber
|
|
24
|
-
* allowlist
|
|
25
|
-
* model
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|