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.
Files changed (134) hide show
  1. package/README.md +106 -76
  2. package/SOUL.md +100 -28
  3. package/config/mcp.json +9 -9
  4. package/package.json +15 -8
  5. package/skills/apple-notes.md +0 -52
  6. package/skills/apple-reminders.md +1 -87
  7. package/skills/camsnap.md +20 -144
  8. package/skills/coding.md +7 -7
  9. package/skills/documents.md +6 -6
  10. package/skills/email.md +6 -6
  11. package/skills/gif-search.md +28 -171
  12. package/skills/healthcheck.md +21 -203
  13. package/skills/image-gen.md +24 -123
  14. package/skills/model-usage.md +18 -165
  15. package/skills/obsidian.md +28 -174
  16. package/skills/pdf.md +30 -181
  17. package/skills/research.md +6 -6
  18. package/skills/skill-creator.md +35 -111
  19. package/skills/spotify.md +2 -17
  20. package/skills/summarize.md +36 -193
  21. package/skills/things.md +23 -175
  22. package/skills/tmux.md +1 -91
  23. package/skills/trello.md +32 -157
  24. package/skills/video-frames.md +26 -166
  25. package/skills/weather.md +6 -6
  26. package/src/a2a/A2AClient.js +2 -2
  27. package/src/a2a/A2AServer.js +6 -6
  28. package/src/a2a/AgentCard.js +2 -2
  29. package/src/agents/SubAgentManager.js +61 -19
  30. package/src/agents/Supervisor.js +4 -4
  31. package/src/channels/BaseChannel.js +6 -6
  32. package/src/channels/BlueBubblesChannel.js +112 -0
  33. package/src/channels/DiscordChannel.js +8 -8
  34. package/src/channels/EmailChannel.js +54 -26
  35. package/src/channels/FeishuChannel.js +140 -0
  36. package/src/channels/GoogleChatChannel.js +8 -8
  37. package/src/channels/HttpChannel.js +2 -2
  38. package/src/channels/IRCChannel.js +144 -0
  39. package/src/channels/LineChannel.js +13 -13
  40. package/src/channels/MatrixChannel.js +97 -0
  41. package/src/channels/MattermostChannel.js +119 -0
  42. package/src/channels/NextcloudChannel.js +133 -0
  43. package/src/channels/NostrChannel.js +175 -0
  44. package/src/channels/SignalChannel.js +9 -9
  45. package/src/channels/SlackChannel.js +10 -10
  46. package/src/channels/TeamsChannel.js +10 -10
  47. package/src/channels/TelegramChannel.js +8 -8
  48. package/src/channels/TwitchChannel.js +128 -0
  49. package/src/channels/WhatsAppChannel.js +10 -10
  50. package/src/channels/ZaloChannel.js +119 -0
  51. package/src/channels/iMessageChannel.js +150 -0
  52. package/src/channels/index.js +241 -11
  53. package/src/cli.js +835 -38
  54. package/src/config/agentProfiles.js +19 -19
  55. package/src/config/channels.js +1 -1
  56. package/src/config/default.js +12 -7
  57. package/src/config/models.js +3 -3
  58. package/src/config/permissions.js +2 -2
  59. package/src/core/AgentLoop.js +13 -13
  60. package/src/core/Compaction.js +3 -3
  61. package/src/core/CostTracker.js +2 -2
  62. package/src/core/EventBus.js +15 -15
  63. package/src/core/TaskQueue.js +24 -7
  64. package/src/core/TaskRunner.js +19 -6
  65. package/src/daemon/DaemonManager.js +4 -4
  66. package/src/hooks/HookRunner.js +4 -4
  67. package/src/index.js +6 -2
  68. package/src/mcp/MCPAgentRunner.js +3 -3
  69. package/src/mcp/MCPClient.js +9 -9
  70. package/src/mcp/MCPManager.js +14 -14
  71. package/src/models/ModelRouter.js +2 -2
  72. package/src/safety/AuditLog.js +3 -3
  73. package/src/safety/CircuitBreaker.js +2 -2
  74. package/src/safety/CommandGuard.js +132 -0
  75. package/src/safety/FilesystemGuard.js +23 -3
  76. package/src/safety/GitRollback.js +5 -5
  77. package/src/safety/HumanApproval.js +9 -9
  78. package/src/safety/InputSanitizer.js +81 -8
  79. package/src/safety/PermissionGuard.js +2 -2
  80. package/src/safety/Sandbox.js +1 -1
  81. package/src/safety/SecretScanner.js +90 -28
  82. package/src/safety/SecretVault.js +2 -2
  83. package/src/scheduler/Heartbeat.js +3 -3
  84. package/src/scheduler/Scheduler.js +6 -6
  85. package/src/setup/theme.js +171 -66
  86. package/src/setup/wizard.js +432 -57
  87. package/src/skills/SkillLoader.js +145 -8
  88. package/src/storage/TaskStore.js +39 -15
  89. package/src/systemPrompt.js +45 -43
  90. package/src/tenants/TenantManager.js +79 -22
  91. package/src/tools/ToolRegistry.js +3 -3
  92. package/src/tools/applyPatch.js +2 -2
  93. package/src/tools/browserAutomation.js +4 -4
  94. package/src/tools/calendar.js +155 -0
  95. package/src/tools/clipboard.js +71 -0
  96. package/src/tools/contacts.js +138 -0
  97. package/src/tools/createDocument.js +2 -2
  98. package/src/tools/cronTool.js +14 -14
  99. package/src/tools/database.js +165 -0
  100. package/src/tools/editFile.js +10 -10
  101. package/src/tools/executeCommand.js +11 -3
  102. package/src/tools/generateImage.js +79 -0
  103. package/src/tools/gitTool.js +141 -0
  104. package/src/tools/glob.js +1 -1
  105. package/src/tools/googlePlaces.js +136 -0
  106. package/src/tools/grep.js +2 -2
  107. package/src/tools/iMessageTool.js +86 -0
  108. package/src/tools/imageAnalysis.js +3 -3
  109. package/src/tools/index.js +56 -2
  110. package/src/tools/makeVoiceCall.js +283 -0
  111. package/src/tools/manageAgents.js +2 -2
  112. package/src/tools/manageMCP.js +38 -20
  113. package/src/tools/memory.js +25 -32
  114. package/src/tools/messageChannel.js +1 -1
  115. package/src/tools/notification.js +90 -0
  116. package/src/tools/philipsHue.js +147 -0
  117. package/src/tools/projectTracker.js +8 -8
  118. package/src/tools/readFile.js +1 -1
  119. package/src/tools/readPDF.js +73 -0
  120. package/src/tools/screenCapture.js +6 -6
  121. package/src/tools/searchContent.js +2 -2
  122. package/src/tools/searchFiles.js +1 -1
  123. package/src/tools/sendEmail.js +79 -24
  124. package/src/tools/sendFile.js +4 -4
  125. package/src/tools/sonos.js +137 -0
  126. package/src/tools/sshTool.js +130 -0
  127. package/src/tools/textToSpeech.js +5 -5
  128. package/src/tools/transcribeAudio.js +4 -4
  129. package/src/tools/useMCP.js +4 -4
  130. package/src/tools/webFetch.js +2 -2
  131. package/src/tools/webSearch.js +1 -1
  132. package/src/utils/Embeddings.js +79 -0
  133. package/src/voice/VoiceSessionManager.js +170 -0
  134. package/src/voice/VoiceWebhook.js +188 -0
@@ -3,22 +3,22 @@ import taskQueue from "../core/TaskQueue.js";
3
3
  import crypto from "crypto";
4
4
 
5
5
  /**
6
- * LINE Channel receives messages via LINE Messaging API webhook.
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 use ngrok or deploy to a server)
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 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
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 need LINE_CHANNEL_ACCESS_TOKEN and LINE_CHANNEL_SECRET`);
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 ${this.config.allowlist.length} authorized user(s)`);
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 passed the validated request body.
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 reject unsigned requests
67
+ // Signature validation - reject unsigned requests
68
68
  if (!this.validateSignature(rawBody, signature)) {
69
- console.log(`[Channel:LINE] Invalid signature request rejected`);
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 process in background and use push message
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 needed for long-running tasks.
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 receives messages via signal-cli REST API.
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 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
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 need SIGNAL_CLI_URL and SIGNAL_PHONE_NUMBER`);
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 polling ${this.cliUrl} for ${this.phoneNumber}`);
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 ${this.config.allowlist.length} authorized number(s)`);
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 network errors during polling are expected if signal-cli restarts
81
+ // Silent - network errors during polling are expected if signal-cli restarts
82
82
  }
83
83
  }
84
84