daemora 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +835 -38
- 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 +3 -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
|
@@ -3,22 +3,22 @@ import taskQueue from "../core/TaskQueue.js";
|
|
|
3
3
|
import crypto from "crypto";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* LINE Channel
|
|
6
|
+
* LINE Channel - receives messages via LINE Messaging API webhook.
|
|
7
7
|
*
|
|
8
8
|
* Setup:
|
|
9
9
|
* 1. Go to https://developers.line.biz → Create a provider → Create a Messaging API channel
|
|
10
10
|
* 2. Under "Messaging API" → Issue a channel access token (long-lived) → LINE_CHANNEL_ACCESS_TOKEN
|
|
11
11
|
* 3. Under "Basic settings" → Channel secret → LINE_CHANNEL_SECRET
|
|
12
12
|
* 4. Set webhook URL to: https://your-server/webhooks/line
|
|
13
|
-
* (requires a public HTTPS URL
|
|
13
|
+
* (requires a public HTTPS URL - use ngrok or deploy to a server)
|
|
14
14
|
* 5. Enable "Use webhook" → Verify
|
|
15
15
|
* 6. Set env: LINE_CHANNEL_SECRET, LINE_CHANNEL_ACCESS_TOKEN
|
|
16
16
|
*
|
|
17
17
|
* Config:
|
|
18
|
-
* accessToken
|
|
19
|
-
* channelSecret
|
|
20
|
-
* allowlist
|
|
21
|
-
* model
|
|
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
22
|
*
|
|
23
23
|
* The bot responds to all direct messages sent to the LINE Official Account.
|
|
24
24
|
*/
|
|
@@ -31,14 +31,14 @@ export class LineChannel extends BaseChannel {
|
|
|
31
31
|
|
|
32
32
|
async start() {
|
|
33
33
|
if (!this.accessToken || !this.channelSecret) {
|
|
34
|
-
console.log(`[Channel:LINE] Skipped
|
|
34
|
+
console.log(`[Channel:LINE] Skipped - need LINE_CHANNEL_ACCESS_TOKEN and LINE_CHANNEL_SECRET`);
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
this.running = true;
|
|
39
39
|
console.log(`[Channel:LINE] Ready (webhook: POST /webhooks/line)`);
|
|
40
40
|
if (this.config.allowlist?.length) {
|
|
41
|
-
console.log(`[Channel:LINE] Allowlist active
|
|
41
|
+
console.log(`[Channel:LINE] Allowlist active - ${this.config.allowlist.length} authorized user(s)`);
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -61,12 +61,12 @@ export class LineChannel extends BaseChannel {
|
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* Handle incoming webhook from LINE.
|
|
64
|
-
* Called from Express route in index.js
|
|
64
|
+
* Called from Express route in index.js - passed the validated request body.
|
|
65
65
|
*/
|
|
66
66
|
async handleWebhook(rawBody, signature, body) {
|
|
67
|
-
// Signature validation
|
|
67
|
+
// Signature validation - reject unsigned requests
|
|
68
68
|
if (!this.validateSignature(rawBody, signature)) {
|
|
69
|
-
console.log(`[Channel:LINE] Invalid signature
|
|
69
|
+
console.log(`[Channel:LINE] Invalid signature - request rejected`);
|
|
70
70
|
return { error: "Invalid signature" };
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -98,7 +98,7 @@ export class LineChannel extends BaseChannel {
|
|
|
98
98
|
model: this.getModel(),
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
// LINE reply tokens expire quickly
|
|
101
|
+
// LINE reply tokens expire quickly - process in background and use push message
|
|
102
102
|
taskQueue.waitForCompletion(task.id).then(async (completedTask) => {
|
|
103
103
|
if (this.isTaskMerged(completedTask)) return; // absorbed into concurrent session
|
|
104
104
|
const response = completedTask.status === "failed"
|
|
@@ -117,7 +117,7 @@ export class LineChannel extends BaseChannel {
|
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
119
|
* Send a message to a LINE user via push message API.
|
|
120
|
-
* Push messages work without a reply token
|
|
120
|
+
* Push messages work without a reply token - needed for long-running tasks.
|
|
121
121
|
*/
|
|
122
122
|
async sendReply(channelMeta, text) {
|
|
123
123
|
if (!this.accessToken || !channelMeta.userId) return;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { BaseChannel } from "./BaseChannel.js";
|
|
2
|
+
import taskQueue from "../core/TaskQueue.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Matrix Channel - receives messages via Matrix (matrix.org / Element protocol).
|
|
6
|
+
*
|
|
7
|
+
* Setup:
|
|
8
|
+
* 1. Register a Matrix bot account (e.g. @mybot:matrix.org)
|
|
9
|
+
* 2. Generate an access token via /_matrix/client/v3/login
|
|
10
|
+
* 3. Set env: MATRIX_HOMESERVER_URL, MATRIX_ACCESS_TOKEN, MATRIX_BOT_USER_ID
|
|
11
|
+
*
|
|
12
|
+
* Config:
|
|
13
|
+
* homeserverUrl - e.g. https://matrix.org
|
|
14
|
+
* accessToken - Matrix access token
|
|
15
|
+
* botUserId - e.g. @mybot:matrix.org
|
|
16
|
+
* allowlist - Optional array of Matrix user IDs
|
|
17
|
+
* model - Optional model override
|
|
18
|
+
*/
|
|
19
|
+
export class MatrixChannel extends BaseChannel {
|
|
20
|
+
constructor(config) {
|
|
21
|
+
super("matrix", config);
|
|
22
|
+
this.client = null;
|
|
23
|
+
this.syncToken = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async start() {
|
|
27
|
+
if (!this.config.homeserverUrl || !this.config.accessToken) {
|
|
28
|
+
console.log("[Channel:Matrix] Skipped - missing MATRIX_HOMESERVER_URL or MATRIX_ACCESS_TOKEN");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const sdk = await import("matrix-js-sdk");
|
|
34
|
+
this.client = sdk.createClient({
|
|
35
|
+
baseUrl: this.config.homeserverUrl,
|
|
36
|
+
accessToken: this.config.accessToken,
|
|
37
|
+
userId: this.config.botUserId,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.client.on("Room.timeline", async (event, room) => {
|
|
41
|
+
if (event.getType() !== "m.room.message") return;
|
|
42
|
+
if (event.getSender() === this.config.botUserId) return;
|
|
43
|
+
|
|
44
|
+
const userId = event.getSender();
|
|
45
|
+
if (!this.isAllowed(userId)) return;
|
|
46
|
+
|
|
47
|
+
const content = event.getContent();
|
|
48
|
+
if (content.msgtype !== "m.text") return;
|
|
49
|
+
|
|
50
|
+
const input = content.body?.trim();
|
|
51
|
+
if (!input) return;
|
|
52
|
+
|
|
53
|
+
const roomId = room.roomId;
|
|
54
|
+
const channelMeta = { roomId, userId, eventId: event.getId() };
|
|
55
|
+
|
|
56
|
+
const task = await taskQueue.enqueue({
|
|
57
|
+
input,
|
|
58
|
+
channel: "matrix",
|
|
59
|
+
sessionId: this.getSessionId(userId),
|
|
60
|
+
channelMeta,
|
|
61
|
+
model: this.getModel(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const result = await taskQueue.waitForResult(task.id);
|
|
65
|
+
if (!this.isTaskMerged(result)) {
|
|
66
|
+
await this.sendReply(channelMeta, result.result || "(no response)");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await this.client.startClient({ initialSyncLimit: 0 });
|
|
71
|
+
this.running = true;
|
|
72
|
+
console.log(`[Channel:Matrix] Connected as ${this.config.botUserId}`);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.log(`[Channel:Matrix] Failed to start: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async stop() {
|
|
79
|
+
if (this.client) {
|
|
80
|
+
this.client.stopClient();
|
|
81
|
+
this.running = false;
|
|
82
|
+
}
|
|
83
|
+
console.log("[Channel:Matrix] Stopped");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async sendReply(channelMeta, text) {
|
|
87
|
+
if (!this.client || !channelMeta?.roomId) return;
|
|
88
|
+
// Split long messages (Matrix limit ~60KB but we keep replies readable)
|
|
89
|
+
const chunks = text.match(/[\s\S]{1,4000}/g) || [text];
|
|
90
|
+
for (const chunk of chunks) {
|
|
91
|
+
await this.client.sendMessage(channelMeta.roomId, {
|
|
92
|
+
msgtype: "m.text",
|
|
93
|
+
body: chunk,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { BaseChannel } from "./BaseChannel.js";
|
|
2
|
+
import taskQueue from "../core/TaskQueue.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Mattermost Channel - receives messages via Mattermost WebSocket API.
|
|
6
|
+
*
|
|
7
|
+
* Setup:
|
|
8
|
+
* 1. Create a bot account in Mattermost (System Console → Integrations → Bot Accounts)
|
|
9
|
+
* 2. Copy the bot access token
|
|
10
|
+
* 3. Set env: MATTERMOST_URL, MATTERMOST_TOKEN, MATTERMOST_BOT_USER_ID
|
|
11
|
+
*
|
|
12
|
+
* Config:
|
|
13
|
+
* url - Mattermost server URL (e.g. https://mattermost.example.com)
|
|
14
|
+
* token - Bot access token
|
|
15
|
+
* botUserId - Bot user ID
|
|
16
|
+
* allowlist - Optional array of Mattermost user IDs
|
|
17
|
+
* model - Optional model override
|
|
18
|
+
*/
|
|
19
|
+
export class MattermostChannel extends BaseChannel {
|
|
20
|
+
constructor(config) {
|
|
21
|
+
super("mattermost", config);
|
|
22
|
+
this.ws = null;
|
|
23
|
+
this.seq = 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async start() {
|
|
27
|
+
if (!this.config.url || !this.config.token) {
|
|
28
|
+
console.log("[Channel:Mattermost] Skipped - missing MATTERMOST_URL or MATTERMOST_TOKEN");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const { WebSocket } = await import("ws");
|
|
34
|
+
const wsUrl = this.config.url.replace(/^http/, "ws") + "/api/v4/websocket";
|
|
35
|
+
|
|
36
|
+
this.ws = new WebSocket(wsUrl);
|
|
37
|
+
|
|
38
|
+
this.ws.on("open", () => {
|
|
39
|
+
// Authenticate
|
|
40
|
+
this.ws.send(JSON.stringify({
|
|
41
|
+
seq: this.seq++,
|
|
42
|
+
action: "authentication_challenge",
|
|
43
|
+
data: { token: this.config.token },
|
|
44
|
+
}));
|
|
45
|
+
this.running = true;
|
|
46
|
+
console.log("[Channel:Mattermost] WebSocket connected");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.ws.on("message", async (raw) => {
|
|
50
|
+
let msg;
|
|
51
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
52
|
+
|
|
53
|
+
if (msg.event !== "posted") return;
|
|
54
|
+
const post = JSON.parse(msg.data?.post || "{}");
|
|
55
|
+
if (!post.message?.trim()) return;
|
|
56
|
+
if (post.user_id === this.config.botUserId) return;
|
|
57
|
+
if (!this.isAllowed(post.user_id)) return;
|
|
58
|
+
|
|
59
|
+
// Only respond to DMs or @mentions
|
|
60
|
+
const isDM = msg.data?.channel_type === "D";
|
|
61
|
+
const isMentioned = post.message.includes(`@${this.config.botUsername || "bot"}`);
|
|
62
|
+
if (!isDM && !isMentioned) return;
|
|
63
|
+
|
|
64
|
+
const input = post.message.replace(/@\S+/g, "").trim() || post.message;
|
|
65
|
+
const channelMeta = { channelId: post.channel_id, userId: post.user_id, postId: post.id };
|
|
66
|
+
|
|
67
|
+
const task = await taskQueue.enqueue({
|
|
68
|
+
input,
|
|
69
|
+
channel: "mattermost",
|
|
70
|
+
sessionId: this.getSessionId(post.user_id),
|
|
71
|
+
channelMeta,
|
|
72
|
+
model: this.getModel(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const result = await taskQueue.waitForResult(task.id);
|
|
76
|
+
if (!this.isTaskMerged(result)) {
|
|
77
|
+
await this.sendReply(channelMeta, result.result || "(no response)");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
this.ws.on("error", (err) => console.log(`[Channel:Mattermost] WS error: ${err.message}`));
|
|
82
|
+
this.ws.on("close", () => {
|
|
83
|
+
this.running = false;
|
|
84
|
+
console.log("[Channel:Mattermost] WebSocket closed");
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.log(`[Channel:Mattermost] Failed to start: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async stop() {
|
|
92
|
+
if (this.ws) {
|
|
93
|
+
this.ws.close();
|
|
94
|
+
this.running = false;
|
|
95
|
+
}
|
|
96
|
+
console.log("[Channel:Mattermost] Stopped");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async sendReply(channelMeta, text) {
|
|
100
|
+
if (!this.config.url || !this.config.token || !channelMeta?.channelId) return;
|
|
101
|
+
const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
|
|
102
|
+
// Split if needed (Mattermost limit 16383 chars per post)
|
|
103
|
+
const chunks = text.match(/[\s\S]{1,4000}/g) || [text];
|
|
104
|
+
for (const chunk of chunks) {
|
|
105
|
+
await fetchFn(`${this.config.url}/api/v4/posts`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: {
|
|
108
|
+
"Authorization": `Bearer ${this.config.token}`,
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
channel_id: channelMeta.channelId,
|
|
113
|
+
message: chunk,
|
|
114
|
+
...(channelMeta.postId ? { root_id: channelMeta.postId } : {}),
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { BaseChannel } from "./BaseChannel.js";
|
|
2
|
+
import taskQueue from "../core/TaskQueue.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Nextcloud Talk Channel - polls Nextcloud Talk for new messages.
|
|
6
|
+
*
|
|
7
|
+
* Setup:
|
|
8
|
+
* 1. Create a bot user in Nextcloud
|
|
9
|
+
* 2. Generate an app password: Profile → Security → Devices & Sessions
|
|
10
|
+
* 3. Set env: NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD
|
|
11
|
+
* Optional: NEXTCLOUD_ROOM_TOKEN (specific room token to monitor)
|
|
12
|
+
*
|
|
13
|
+
* Config:
|
|
14
|
+
* url - Nextcloud instance URL (e.g. https://cloud.example.com)
|
|
15
|
+
* user - Nextcloud username
|
|
16
|
+
* password - Nextcloud app password
|
|
17
|
+
* roomToken - Optional: specific room token to monitor (monitors all DMs if omitted)
|
|
18
|
+
* pollIntervalMs - Polling interval in ms (default 5000)
|
|
19
|
+
* allowlist - Optional array of Nextcloud usernames
|
|
20
|
+
* model - Optional model override
|
|
21
|
+
*/
|
|
22
|
+
export class NextcloudChannel extends BaseChannel {
|
|
23
|
+
constructor(config) {
|
|
24
|
+
super("nextcloud", config);
|
|
25
|
+
this.pollInterval = null;
|
|
26
|
+
this.pollMs = config.pollIntervalMs || 5000;
|
|
27
|
+
this.lastMessageIds = {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get _headers() {
|
|
31
|
+
const creds = Buffer.from(`${this.config.user}:${this.config.password}`).toString("base64");
|
|
32
|
+
return {
|
|
33
|
+
"Authorization": `Basic ${creds}`,
|
|
34
|
+
"OCS-APIRequest": "true",
|
|
35
|
+
"Accept": "application/json",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async start() {
|
|
40
|
+
if (!this.config.url || !this.config.user || !this.config.password) {
|
|
41
|
+
console.log("[Channel:Nextcloud] Skipped - missing NEXTCLOUD_URL, NEXTCLOUD_USER, or NEXTCLOUD_PASSWORD");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.running = true;
|
|
46
|
+
this.pollInterval = setInterval(() => this._poll(), this.pollMs);
|
|
47
|
+
console.log(`[Channel:Nextcloud] Polling ${this.config.url} every ${this.pollMs}ms`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async _poll() {
|
|
51
|
+
const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
|
|
52
|
+
try {
|
|
53
|
+
// Get list of conversations
|
|
54
|
+
const convsRes = await fetchFn(
|
|
55
|
+
`${this.config.url}/ocs/v2.php/apps/spreed/api/v4/room`,
|
|
56
|
+
{ headers: this._headers }
|
|
57
|
+
);
|
|
58
|
+
const convsData = await convsRes.json();
|
|
59
|
+
const rooms = convsData?.ocs?.data || [];
|
|
60
|
+
|
|
61
|
+
for (const room of rooms) {
|
|
62
|
+
const token = room.token;
|
|
63
|
+
if (this.config.roomToken && token !== this.config.roomToken) continue;
|
|
64
|
+
if (room.type !== 1 && room.type !== 3) continue; // 1=DM, 3=group
|
|
65
|
+
|
|
66
|
+
const lastKnown = this.lastMessageIds[token] || 0;
|
|
67
|
+
|
|
68
|
+
const msgsRes = await fetchFn(
|
|
69
|
+
`${this.config.url}/ocs/v2.php/apps/spreed/api/v1/chat/${token}?lookIntoFuture=0&limit=10&lastKnownMessageId=${lastKnown}`,
|
|
70
|
+
{ headers: this._headers }
|
|
71
|
+
);
|
|
72
|
+
const msgsData = await msgsRes.json();
|
|
73
|
+
const messages = msgsData?.ocs?.data || [];
|
|
74
|
+
|
|
75
|
+
for (const msg of messages) {
|
|
76
|
+
if (msg.actorId === this.config.user) continue;
|
|
77
|
+
if (msg.messageType !== "comment") continue;
|
|
78
|
+
if (msg.id <= lastKnown) continue;
|
|
79
|
+
|
|
80
|
+
this.lastMessageIds[token] = Math.max(this.lastMessageIds[token] || 0, msg.id);
|
|
81
|
+
|
|
82
|
+
if (!this.isAllowed(msg.actorId)) continue;
|
|
83
|
+
|
|
84
|
+
const input = msg.message?.trim();
|
|
85
|
+
if (!input) continue;
|
|
86
|
+
|
|
87
|
+
const channelMeta = { token, actorId: msg.actorId, messageId: msg.id };
|
|
88
|
+
const task = await taskQueue.enqueue({
|
|
89
|
+
input,
|
|
90
|
+
channel: "nextcloud",
|
|
91
|
+
sessionId: this.getSessionId(msg.actorId),
|
|
92
|
+
channelMeta,
|
|
93
|
+
model: this.getModel(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
taskQueue.waitForResult(task.id).then(result => {
|
|
97
|
+
if (!this.isTaskMerged(result)) {
|
|
98
|
+
this.sendReply(channelMeta, result.result || "(no response)");
|
|
99
|
+
}
|
|
100
|
+
}).catch(() => {});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Silent polling errors
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async stop() {
|
|
109
|
+
if (this.pollInterval) {
|
|
110
|
+
clearInterval(this.pollInterval);
|
|
111
|
+
this.pollInterval = null;
|
|
112
|
+
}
|
|
113
|
+
this.running = false;
|
|
114
|
+
console.log("[Channel:Nextcloud] Stopped");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async sendReply(channelMeta, text) {
|
|
118
|
+
if (!channelMeta?.token) return;
|
|
119
|
+
const fetchFn = globalThis.fetch || (await import("node-fetch")).default;
|
|
120
|
+
const chunks = text.match(/[\s\S]{1,32000}/g) || [text];
|
|
121
|
+
for (const chunk of chunks) {
|
|
122
|
+
const body = new URLSearchParams({ message: chunk });
|
|
123
|
+
await fetchFn(
|
|
124
|
+
`${this.config.url}/ocs/v2.php/apps/spreed/api/v1/chat/${channelMeta.token}`,
|
|
125
|
+
{
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { ...this._headers, "Content-Type": "application/x-www-form-urlencoded" },
|
|
128
|
+
body: body.toString(),
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { BaseChannel } from "./BaseChannel.js";
|
|
2
|
+
import taskQueue from "../core/TaskQueue.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Nostr Channel - receives and responds to Nostr direct messages (NIP-04 encrypted DMs).
|
|
6
|
+
* Connects to one or more Nostr relays via WebSocket.
|
|
7
|
+
*
|
|
8
|
+
* Setup:
|
|
9
|
+
* 1. Generate a Nostr keypair: `node -e "const {generateSecretKey,getPublicKey}=require('@noble/secp256k1');const sk=generateSecretKey();console.log('SK:',Buffer.from(sk).toString('hex'));console.log('PK:',getPublicKey(sk))"`
|
|
10
|
+
* 2. Set env: NOSTR_PRIVATE_KEY (hex), NOSTR_RELAYS (comma-separated relay URLs)
|
|
11
|
+
*
|
|
12
|
+
* Config:
|
|
13
|
+
* privateKey - Bot's Nostr private key (hex string)
|
|
14
|
+
* relays - Array of relay URLs (wss://relay.damus.io, etc.)
|
|
15
|
+
* allowlist - Optional array of allowed public keys (npub or hex)
|
|
16
|
+
* model - Optional model override
|
|
17
|
+
*
|
|
18
|
+
* Implements NIP-04 encrypted DMs (kind 4).
|
|
19
|
+
*/
|
|
20
|
+
export class NostrChannel extends BaseChannel {
|
|
21
|
+
constructor(config) {
|
|
22
|
+
super("nostr", config);
|
|
23
|
+
this.relayConnections = [];
|
|
24
|
+
this.privateKey = null;
|
|
25
|
+
this.publicKey = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async start() {
|
|
29
|
+
if (!this.config.privateKey) {
|
|
30
|
+
console.log("[Channel:Nostr] Skipped - missing NOSTR_PRIVATE_KEY");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const relays = this.config.relays || ["wss://relay.damus.io", "wss://nos.lol"];
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Use @noble/curves for crypto (NIP-04 requires secp256k1)
|
|
38
|
+
const { secp256k1, schnorr } = await import("@noble/curves/secp256k1").catch(async () => {
|
|
39
|
+
throw new Error("@noble/curves package not found. Run: npm install @noble/curves");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const skBytes = Buffer.from(this.config.privateKey, "hex");
|
|
43
|
+
this.privateKey = skBytes;
|
|
44
|
+
this.publicKey = Buffer.from(secp256k1.getPublicKey(skBytes, true)).toString("hex").slice(2); // remove 02/03 prefix
|
|
45
|
+
|
|
46
|
+
const { WebSocket } = await import("ws");
|
|
47
|
+
|
|
48
|
+
for (const relayUrl of relays) {
|
|
49
|
+
this._connectRelay(relayUrl, WebSocket, secp256k1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.running = true;
|
|
53
|
+
console.log(`[Channel:Nostr] Listening on ${relays.length} relay(s). pubkey: ${this.publicKey.slice(0, 16)}...`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.log(`[Channel:Nostr] Failed to start: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_connectRelay(relayUrl, WebSocket, secp256k1) {
|
|
60
|
+
const ws = new WebSocket(relayUrl);
|
|
61
|
+
|
|
62
|
+
ws.on("open", () => {
|
|
63
|
+
// Subscribe to DMs (kind 4) where we are the recipient (#p tag)
|
|
64
|
+
const subId = "dm-sub-" + Date.now();
|
|
65
|
+
ws.send(JSON.stringify([
|
|
66
|
+
"REQ",
|
|
67
|
+
subId,
|
|
68
|
+
{ kinds: [4], "#p": [this.publicKey], since: Math.floor(Date.now() / 1000) - 60 },
|
|
69
|
+
]));
|
|
70
|
+
console.log(`[Channel:Nostr] Subscribed on ${relayUrl}`);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
ws.on("message", async (raw) => {
|
|
74
|
+
let msg;
|
|
75
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
76
|
+
if (msg[0] !== "EVENT") return;
|
|
77
|
+
|
|
78
|
+
const event = msg[2];
|
|
79
|
+
if (event.kind !== 4) return;
|
|
80
|
+
if (event.pubkey === this.publicKey) return; // ignore our own
|
|
81
|
+
|
|
82
|
+
const senderPubkey = event.pubkey;
|
|
83
|
+
if (!this.isAllowed(senderPubkey)) return;
|
|
84
|
+
|
|
85
|
+
// Decrypt NIP-04 DM
|
|
86
|
+
let input;
|
|
87
|
+
try {
|
|
88
|
+
const { nip04Decrypt } = this._nip04(secp256k1);
|
|
89
|
+
input = await nip04Decrypt(this.privateKey, senderPubkey, event.content);
|
|
90
|
+
} catch { return; }
|
|
91
|
+
|
|
92
|
+
if (!input?.trim()) return;
|
|
93
|
+
|
|
94
|
+
const channelMeta = { senderPubkey, relayUrl, eventId: event.id };
|
|
95
|
+
|
|
96
|
+
const task = await taskQueue.enqueue({
|
|
97
|
+
input: input.trim(),
|
|
98
|
+
channel: "nostr",
|
|
99
|
+
sessionId: this.getSessionId(senderPubkey),
|
|
100
|
+
channelMeta,
|
|
101
|
+
model: this.getModel(),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const result = await taskQueue.waitForResult(task.id);
|
|
105
|
+
if (!this.isTaskMerged(result)) {
|
|
106
|
+
await this.sendReply(channelMeta, result.result || "(no response)");
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
ws.on("error", (err) => console.log(`[Channel:Nostr] ${relayUrl} error: ${err.message}`));
|
|
111
|
+
this.relayConnections.push(ws);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_nip04(secp256k1) {
|
|
115
|
+
const nip04Decrypt = async (privKey, pubKeyHex, encryptedContent) => {
|
|
116
|
+
const [ciphertext, ivB64] = encryptedContent.split("?iv=");
|
|
117
|
+
const sharedPoint = secp256k1.getSharedSecret(privKey, "02" + pubKeyHex);
|
|
118
|
+
const sharedX = sharedPoint.slice(1, 33);
|
|
119
|
+
const { subtle } = globalThis.crypto;
|
|
120
|
+
const key = await subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["decrypt"]);
|
|
121
|
+
const iv = Buffer.from(ivB64, "base64");
|
|
122
|
+
const data = Buffer.from(ciphertext, "base64");
|
|
123
|
+
const decrypted = await subtle.decrypt({ name: "AES-CBC", iv }, key, data);
|
|
124
|
+
return new TextDecoder().decode(decrypted);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const nip04Encrypt = async (privKey, pubKeyHex, plaintext) => {
|
|
128
|
+
const sharedPoint = secp256k1.getSharedSecret(privKey, "02" + pubKeyHex);
|
|
129
|
+
const sharedX = sharedPoint.slice(1, 33);
|
|
130
|
+
const { subtle } = globalThis.crypto;
|
|
131
|
+
const key = await subtle.importKey("raw", sharedX, { name: "AES-CBC" }, false, ["encrypt"]);
|
|
132
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
133
|
+
const data = new TextEncoder().encode(plaintext);
|
|
134
|
+
const encrypted = await subtle.encrypt({ name: "AES-CBC", iv }, key, data);
|
|
135
|
+
return Buffer.from(encrypted).toString("base64") + "?iv=" + Buffer.from(iv).toString("base64");
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return { nip04Decrypt, nip04Encrypt };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async stop() {
|
|
142
|
+
for (const ws of this.relayConnections) {
|
|
143
|
+
ws.close();
|
|
144
|
+
}
|
|
145
|
+
this.relayConnections = [];
|
|
146
|
+
this.running = false;
|
|
147
|
+
console.log("[Channel:Nostr] Stopped");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async sendReply(channelMeta, text) {
|
|
151
|
+
if (!channelMeta?.senderPubkey || !this.relayConnections.length) return;
|
|
152
|
+
|
|
153
|
+
const { secp256k1 } = await import("@noble/curves/secp256k1").catch(() => null);
|
|
154
|
+
if (!secp256k1) return;
|
|
155
|
+
|
|
156
|
+
const { nip04Encrypt } = this._nip04(secp256k1);
|
|
157
|
+
const { schnorr } = await import("@noble/curves/secp256k1");
|
|
158
|
+
|
|
159
|
+
const content = await nip04Encrypt(this.privateKey, channelMeta.senderPubkey, text);
|
|
160
|
+
const now = Math.floor(Date.now() / 1000);
|
|
161
|
+
|
|
162
|
+
const eventData = [0, this.publicKey, now, 4, [["p", channelMeta.senderPubkey]], content];
|
|
163
|
+
const hash = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(eventData)));
|
|
164
|
+
const id = Buffer.from(hash).toString("hex");
|
|
165
|
+
const sig = Buffer.from(schnorr.sign(Buffer.from(id, "hex"), this.privateKey)).toString("hex");
|
|
166
|
+
|
|
167
|
+
const signedEvent = { id, pubkey: this.publicKey, created_at: now, kind: 4, tags: [["p", channelMeta.senderPubkey]], content, sig };
|
|
168
|
+
|
|
169
|
+
for (const ws of this.relayConnections) {
|
|
170
|
+
if (ws.readyState === 1) {
|
|
171
|
+
ws.send(JSON.stringify(["EVENT", signedEvent]));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -2,7 +2,7 @@ import { BaseChannel } from "./BaseChannel.js";
|
|
|
2
2
|
import taskQueue from "../core/TaskQueue.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Signal Channel
|
|
5
|
+
* Signal Channel - receives messages via signal-cli REST API.
|
|
6
6
|
*
|
|
7
7
|
* signal-cli is an open-source CLI tool that acts as a Signal client.
|
|
8
8
|
* It must be running separately as a daemon before Daemora starts.
|
|
@@ -19,10 +19,10 @@ import taskQueue from "../core/TaskQueue.js";
|
|
|
19
19
|
* SIGNAL_PHONE_NUMBER=+1234567890 (your registered number)
|
|
20
20
|
*
|
|
21
21
|
* Config:
|
|
22
|
-
* cliUrl
|
|
23
|
-
* phoneNumber
|
|
24
|
-
* allowlist
|
|
25
|
-
* model
|
|
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
26
|
*
|
|
27
27
|
* Daemora polls signal-cli every 2 seconds for new messages.
|
|
28
28
|
* All replies are sent back through signal-cli.
|
|
@@ -38,7 +38,7 @@ export class SignalChannel extends BaseChannel {
|
|
|
38
38
|
|
|
39
39
|
async start() {
|
|
40
40
|
if (!this.cliUrl || !this.phoneNumber) {
|
|
41
|
-
console.log(`[Channel:Signal] Skipped
|
|
41
|
+
console.log(`[Channel:Signal] Skipped - need SIGNAL_CLI_URL and SIGNAL_PHONE_NUMBER`);
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -53,9 +53,9 @@ export class SignalChannel extends BaseChannel {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
this.running = true;
|
|
56
|
-
console.log(`[Channel:Signal] Started
|
|
56
|
+
console.log(`[Channel:Signal] Started - polling ${this.cliUrl} for ${this.phoneNumber}`);
|
|
57
57
|
if (this.config.allowlist?.length) {
|
|
58
|
-
console.log(`[Channel:Signal] Allowlist active
|
|
58
|
+
console.log(`[Channel:Signal] Allowlist active - ${this.config.allowlist.length} authorized number(s)`);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
// Poll for new messages every 2 seconds
|
|
@@ -78,7 +78,7 @@ export class SignalChannel extends BaseChannel {
|
|
|
78
78
|
await this._handleEnvelope(envelope);
|
|
79
79
|
}
|
|
80
80
|
} catch (_) {
|
|
81
|
-
// Silent
|
|
81
|
+
// Silent - network errors during polling are expected if signal-cli restarts
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|