daemora 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +666 -0
- package/SOUL.md +104 -0
- package/config/hooks.json +14 -0
- package/config/mcp.json +145 -0
- package/package.json +86 -0
- package/skills/.gitkeep +0 -0
- package/skills/apple-notes.md +193 -0
- package/skills/apple-reminders.md +189 -0
- package/skills/camsnap.md +162 -0
- package/skills/coding.md +14 -0
- package/skills/documents.md +13 -0
- package/skills/email.md +13 -0
- package/skills/gif-search.md +196 -0
- package/skills/healthcheck.md +225 -0
- package/skills/image-gen.md +147 -0
- package/skills/model-usage.md +182 -0
- package/skills/obsidian.md +207 -0
- package/skills/pdf.md +211 -0
- package/skills/research.md +13 -0
- package/skills/skill-creator.md +142 -0
- package/skills/spotify.md +149 -0
- package/skills/summarize.md +230 -0
- package/skills/things.md +199 -0
- package/skills/tmux.md +204 -0
- package/skills/trello.md +183 -0
- package/skills/video-frames.md +202 -0
- package/skills/weather.md +127 -0
- package/src/a2a/A2AClient.js +136 -0
- package/src/a2a/A2AServer.js +316 -0
- package/src/a2a/AgentCard.js +79 -0
- package/src/agents/SubAgentManager.js +369 -0
- package/src/agents/Supervisor.js +192 -0
- package/src/channels/BaseChannel.js +104 -0
- package/src/channels/DiscordChannel.js +288 -0
- package/src/channels/EmailChannel.js +172 -0
- package/src/channels/GoogleChatChannel.js +316 -0
- package/src/channels/HttpChannel.js +26 -0
- package/src/channels/LineChannel.js +168 -0
- package/src/channels/SignalChannel.js +186 -0
- package/src/channels/SlackChannel.js +329 -0
- package/src/channels/TeamsChannel.js +272 -0
- package/src/channels/TelegramChannel.js +347 -0
- package/src/channels/WhatsAppChannel.js +219 -0
- package/src/channels/index.js +198 -0
- package/src/cli.js +1267 -0
- package/src/config/agentProfiles.js +120 -0
- package/src/config/channels.js +32 -0
- package/src/config/default.js +206 -0
- package/src/config/models.js +123 -0
- package/src/config/permissions.js +167 -0
- package/src/core/AgentLoop.js +446 -0
- package/src/core/Compaction.js +143 -0
- package/src/core/CostTracker.js +116 -0
- package/src/core/EventBus.js +46 -0
- package/src/core/Task.js +67 -0
- package/src/core/TaskQueue.js +206 -0
- package/src/core/TaskRunner.js +226 -0
- package/src/daemon/DaemonManager.js +301 -0
- package/src/hooks/HookRunner.js +230 -0
- package/src/index.js +482 -0
- package/src/mcp/MCPAgentRunner.js +112 -0
- package/src/mcp/MCPClient.js +186 -0
- package/src/mcp/MCPManager.js +412 -0
- package/src/models/ModelRouter.js +180 -0
- package/src/safety/AuditLog.js +135 -0
- package/src/safety/CircuitBreaker.js +126 -0
- package/src/safety/FilesystemGuard.js +169 -0
- package/src/safety/GitRollback.js +139 -0
- package/src/safety/HumanApproval.js +156 -0
- package/src/safety/InputSanitizer.js +72 -0
- package/src/safety/PermissionGuard.js +83 -0
- package/src/safety/Sandbox.js +70 -0
- package/src/safety/SecretScanner.js +100 -0
- package/src/safety/SecretVault.js +250 -0
- package/src/scheduler/Heartbeat.js +115 -0
- package/src/scheduler/Scheduler.js +228 -0
- package/src/services/models/outputSchema.js +15 -0
- package/src/services/openai.js +25 -0
- package/src/services/sessions.js +65 -0
- package/src/setup/theme.js +110 -0
- package/src/setup/wizard.js +788 -0
- package/src/skills/SkillLoader.js +168 -0
- package/src/storage/TaskStore.js +69 -0
- package/src/systemPrompt.js +526 -0
- package/src/tenants/TenantContext.js +19 -0
- package/src/tenants/TenantManager.js +379 -0
- package/src/tools/ToolRegistry.js +141 -0
- package/src/tools/applyPatch.js +144 -0
- package/src/tools/browserAutomation.js +223 -0
- package/src/tools/createDocument.js +265 -0
- package/src/tools/cronTool.js +105 -0
- package/src/tools/editFile.js +139 -0
- package/src/tools/executeCommand.js +123 -0
- package/src/tools/glob.js +67 -0
- package/src/tools/grep.js +121 -0
- package/src/tools/imageAnalysis.js +120 -0
- package/src/tools/index.js +173 -0
- package/src/tools/listDirectory.js +47 -0
- package/src/tools/manageAgents.js +47 -0
- package/src/tools/manageMCP.js +159 -0
- package/src/tools/memory.js +478 -0
- package/src/tools/messageChannel.js +45 -0
- package/src/tools/projectTracker.js +259 -0
- package/src/tools/readFile.js +52 -0
- package/src/tools/screenCapture.js +112 -0
- package/src/tools/searchContent.js +76 -0
- package/src/tools/searchFiles.js +75 -0
- package/src/tools/sendEmail.js +118 -0
- package/src/tools/sendFile.js +63 -0
- package/src/tools/textToSpeech.js +161 -0
- package/src/tools/transcribeAudio.js +82 -0
- package/src/tools/useMCP.js +29 -0
- package/src/tools/webFetch.js +150 -0
- package/src/tools/webSearch.js +134 -0
- package/src/tools/writeFile.js +26 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { BaseChannel } from "./BaseChannel.js";
|
|
2
|
+
import taskQueue from "../core/TaskQueue.js";
|
|
3
|
+
import eventBus from "../core/EventBus.js";
|
|
4
|
+
import { transcribeAudio } from "../tools/transcribeAudio.js";
|
|
5
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { join, extname, basename } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discord Channel — receives messages via Discord Bot API.
|
|
11
|
+
*
|
|
12
|
+
* Setup:
|
|
13
|
+
* 1. Go to https://discord.com/developers/applications
|
|
14
|
+
* 2. Create a new application → Bot → copy the token
|
|
15
|
+
* 3. Enable "Message Content Intent" under Bot → Privileged Gateway Intents
|
|
16
|
+
* 4. Invite bot to your server with permissions: Send Messages, Read Message History, Add Reactions
|
|
17
|
+
* 5. Set env: DISCORD_BOT_TOKEN
|
|
18
|
+
*
|
|
19
|
+
* Config:
|
|
20
|
+
* token — Bot token
|
|
21
|
+
* allowlist — Optional array of Discord user IDs (snowflakes) allowed to use the bot
|
|
22
|
+
* model — Optional model override
|
|
23
|
+
*
|
|
24
|
+
* The bot responds to:
|
|
25
|
+
* - Direct messages (DMs)
|
|
26
|
+
* - Messages that @mention the bot in any channel
|
|
27
|
+
*/
|
|
28
|
+
export class DiscordChannel extends BaseChannel {
|
|
29
|
+
constructor(config) {
|
|
30
|
+
super("discord", config);
|
|
31
|
+
this.client = null;
|
|
32
|
+
this.botUserId = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async start() {
|
|
36
|
+
if (!this.config.token) {
|
|
37
|
+
console.log(`[Channel:Discord] Skipped — no DISCORD_BOT_TOKEN`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { Client, GatewayIntentBits, Partials } = await import("discord.js");
|
|
42
|
+
|
|
43
|
+
this.client = new Client({
|
|
44
|
+
intents: [
|
|
45
|
+
GatewayIntentBits.Guilds,
|
|
46
|
+
GatewayIntentBits.GuildMessages,
|
|
47
|
+
GatewayIntentBits.MessageContent,
|
|
48
|
+
GatewayIntentBits.DirectMessages,
|
|
49
|
+
GatewayIntentBits.DirectMessageTyping,
|
|
50
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
51
|
+
GatewayIntentBits.DirectMessageReactions,
|
|
52
|
+
],
|
|
53
|
+
partials: [Partials.Channel, Partials.Message, Partials.Reaction],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.client.once("ready", (c) => {
|
|
57
|
+
this.botUserId = c.user.id;
|
|
58
|
+
this.running = true;
|
|
59
|
+
console.log(`[Channel:Discord] Logged in as ${c.user.tag}`);
|
|
60
|
+
if (this.config.allowlist?.length) {
|
|
61
|
+
console.log(`[Channel:Discord] Allowlist active — ${this.config.allowlist.length} authorized user(s)`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.client.on("messageCreate", async (message) => {
|
|
66
|
+
// Ignore messages from bots (including self)
|
|
67
|
+
if (message.author.bot) return;
|
|
68
|
+
|
|
69
|
+
const isDM = message.channel.type === 1; // DM_CHANNEL = 1
|
|
70
|
+
const isMention = this.botUserId && message.mentions.has(this.botUserId);
|
|
71
|
+
|
|
72
|
+
// Only respond to DMs or direct @mentions
|
|
73
|
+
if (!isDM && !isMention) return;
|
|
74
|
+
|
|
75
|
+
const userId = message.author.id;
|
|
76
|
+
|
|
77
|
+
// Allowlist check
|
|
78
|
+
if (!this.isAllowed(userId)) {
|
|
79
|
+
console.log(`[Channel:Discord] Blocked (not in allowlist): ${userId}`);
|
|
80
|
+
await message.reply("You are not authorized to use this agent.").catch(() => {});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Strip the @mention from the message content
|
|
85
|
+
const text = message.content
|
|
86
|
+
.replace(/<@!?\d+>/g, "")
|
|
87
|
+
.trim();
|
|
88
|
+
|
|
89
|
+
const hasAttachments = message.attachments.size > 0;
|
|
90
|
+
|
|
91
|
+
if (!text && !hasAttachments) {
|
|
92
|
+
await message.reply("Send me a message and I'll get to work.").catch(() => {});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const channelId = message.channelId;
|
|
97
|
+
|
|
98
|
+
console.log(`[Channel:Discord] Message from ${message.author.username} (${userId}): "${text.slice(0, 80)}"${hasAttachments ? ` + ${message.attachments.size} attachment(s)` : ""}`);
|
|
99
|
+
|
|
100
|
+
// Show typing indicator while processing
|
|
101
|
+
try { await message.channel.sendTyping(); } catch (_) {}
|
|
102
|
+
|
|
103
|
+
// React ⏳ to show we received it
|
|
104
|
+
await this.sendReaction({ message }, "⏳");
|
|
105
|
+
|
|
106
|
+
// Build task input: text + any attachments
|
|
107
|
+
const inputParts = text ? [text] : [];
|
|
108
|
+
for (const [, attachment] of message.attachments) {
|
|
109
|
+
const localPath = await this._downloadAttachment(attachment);
|
|
110
|
+
if (!localPath) continue;
|
|
111
|
+
|
|
112
|
+
const ct = attachment.contentType || "";
|
|
113
|
+
if (ct.startsWith("audio/")) {
|
|
114
|
+
console.log(`[Channel:Discord] Audio attachment — transcribing...`);
|
|
115
|
+
const transcript = await transcribeAudio(localPath);
|
|
116
|
+
inputParts.push(transcript.startsWith("Error:")
|
|
117
|
+
? `[Audio file: ${localPath}]\n${transcript}`
|
|
118
|
+
: `[Audio transcript]: ${transcript}`);
|
|
119
|
+
} else if (ct.startsWith("image/")) {
|
|
120
|
+
inputParts.push(`[Photo received: ${localPath}]\nUser caption: ${attachment.description || text || "Describe and respond to this image."}`);
|
|
121
|
+
} else if (ct.startsWith("video/")) {
|
|
122
|
+
inputParts.push(`[Video received: ${localPath}]`);
|
|
123
|
+
} else {
|
|
124
|
+
inputParts.push(`[File received: ${localPath} (${attachment.name || "document"}, ${_fmtSize(attachment.size)})]`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const input = inputParts.join("\n");
|
|
129
|
+
|
|
130
|
+
// Enqueue task
|
|
131
|
+
const task = taskQueue.enqueue({
|
|
132
|
+
input,
|
|
133
|
+
channel: "discord",
|
|
134
|
+
channelMeta: { userId, channelId, messageId: message.id, guildId: message.guildId, channel: "discord" },
|
|
135
|
+
sessionId: this.getSessionId(userId),
|
|
136
|
+
model: this.getModel(),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const completedTask = await taskQueue.waitForCompletion(task.id);
|
|
141
|
+
|
|
142
|
+
// Absorbed into a concurrent session — response already sent via original task
|
|
143
|
+
if (this.isTaskMerged(completedTask)) {
|
|
144
|
+
await this._removeReaction(message, "⏳");
|
|
145
|
+
await this.sendReaction({ message }, "✅");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const failed = completedTask.status === "failed";
|
|
150
|
+
const response = failed
|
|
151
|
+
? `Sorry, I encountered an error: ${completedTask.error}`
|
|
152
|
+
: completedTask.result || "Done.";
|
|
153
|
+
|
|
154
|
+
// Remove ⏳ and add ✅ or ❌
|
|
155
|
+
await this._removeReaction(message, "⏳");
|
|
156
|
+
await this.sendReaction({ message }, failed ? "❌" : "✅");
|
|
157
|
+
|
|
158
|
+
// Discord message limit: 2000 chars
|
|
159
|
+
const chunks = splitMessage(response, 1990);
|
|
160
|
+
await message.reply(chunks[0]).catch(() => {});
|
|
161
|
+
for (let i = 1; i < chunks.length; i++) {
|
|
162
|
+
await message.channel.send(chunks[i]).catch(() => {});
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error(`[Channel:Discord] Error:`, error.message);
|
|
166
|
+
await this._removeReaction(message, "⏳");
|
|
167
|
+
await this.sendReaction({ message }, "❌");
|
|
168
|
+
try { await message.reply("Sorry, something went wrong. Please try again."); } catch (_) {}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Listen for approval requests
|
|
173
|
+
eventBus.on("approval:request", async (data) => {
|
|
174
|
+
if (data.channelMeta?.channel !== "discord") return;
|
|
175
|
+
const channel = await this.client.channels.fetch(data.channelMeta?.channelId).catch(() => null);
|
|
176
|
+
if (channel) await channel.send(data.message).catch(() => {});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await this.client.login(this.config.token);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.log(`[Channel:Discord] Login failed: ${err.message}`);
|
|
183
|
+
this.running = false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async stop() {
|
|
188
|
+
if (this.client) {
|
|
189
|
+
await this.client.destroy();
|
|
190
|
+
this.running = false;
|
|
191
|
+
console.log(`[Channel:Discord] Stopped`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async sendReply(channelMeta, text) {
|
|
196
|
+
if (!this.client) return;
|
|
197
|
+
try {
|
|
198
|
+
const channel = await this.client.channels.fetch(channelMeta.channelId);
|
|
199
|
+
const chunks = splitMessage(text, 1990);
|
|
200
|
+
for (const chunk of chunks) {
|
|
201
|
+
await channel.send(chunk);
|
|
202
|
+
}
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.log(`[Channel:Discord] sendReply error: ${err.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* React to a Discord message with an emoji.
|
|
210
|
+
* @param {{ message }} channelMeta - Must include the original Discord Message object
|
|
211
|
+
* @param {string} emoji
|
|
212
|
+
*/
|
|
213
|
+
async sendReaction(channelMeta, emoji) {
|
|
214
|
+
const msg = channelMeta?.message;
|
|
215
|
+
if (!msg) return;
|
|
216
|
+
try {
|
|
217
|
+
await msg.react(emoji);
|
|
218
|
+
} catch (_) {}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Remove a specific reaction emoji the bot added. */
|
|
222
|
+
async _removeReaction(message, emoji) {
|
|
223
|
+
try {
|
|
224
|
+
const reaction = message.reactions.cache.get(emoji);
|
|
225
|
+
if (reaction) await reaction.users.remove(this.client.user.id);
|
|
226
|
+
} catch (_) {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Send a local file to a Discord channel.
|
|
231
|
+
*/
|
|
232
|
+
async sendFile(channelMeta, filePath, caption) {
|
|
233
|
+
if (!this.client) return;
|
|
234
|
+
try {
|
|
235
|
+
const channel = await this.client.channels.fetch(channelMeta.channelId);
|
|
236
|
+
await channel.send({
|
|
237
|
+
content: caption || undefined,
|
|
238
|
+
files: [{ attachment: filePath, name: basename(filePath) }],
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.log(`[Channel:Discord] sendFile error: ${err.message}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Download a Discord attachment to /tmp and return the local path.
|
|
247
|
+
*/
|
|
248
|
+
async _downloadAttachment(attachment) {
|
|
249
|
+
try {
|
|
250
|
+
const ext = extname(attachment.name || attachment.url || "").split("?")[0] || "";
|
|
251
|
+
const tmpDir = join(tmpdir(), "daemora-discord");
|
|
252
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
253
|
+
const filePath = join(tmpDir, `${attachment.id}${ext}`);
|
|
254
|
+
|
|
255
|
+
const res = await fetch(attachment.url, { signal: AbortSignal.timeout(30000) });
|
|
256
|
+
if (!res.ok) return null;
|
|
257
|
+
|
|
258
|
+
const buffer = await res.arrayBuffer();
|
|
259
|
+
writeFileSync(filePath, Buffer.from(buffer));
|
|
260
|
+
return filePath;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.log(`[Channel:Discord] Attachment download error: ${err.message}`);
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function _fmtSize(bytes) {
|
|
269
|
+
if (!bytes) return "unknown size";
|
|
270
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
271
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
272
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function splitMessage(text, maxLength) {
|
|
276
|
+
if (text.length <= maxLength) return [text];
|
|
277
|
+
const chunks = [];
|
|
278
|
+
let remaining = text;
|
|
279
|
+
while (remaining.length > 0) {
|
|
280
|
+
if (remaining.length <= maxLength) { chunks.push(remaining); break; }
|
|
281
|
+
let idx = remaining.lastIndexOf("\n", maxLength);
|
|
282
|
+
if (idx < maxLength * 0.5) idx = remaining.lastIndexOf(" ", maxLength);
|
|
283
|
+
if (idx === -1) idx = maxLength;
|
|
284
|
+
chunks.push(remaining.slice(0, idx));
|
|
285
|
+
remaining = remaining.slice(idx).trimStart();
|
|
286
|
+
}
|
|
287
|
+
return chunks;
|
|
288
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { BaseChannel } from "./BaseChannel.js";
|
|
2
|
+
import taskQueue from "../core/TaskQueue.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Email Channel — polls IMAP for incoming emails, replies via SMTP.
|
|
6
|
+
*
|
|
7
|
+
* Setup:
|
|
8
|
+
* 1. Enable IMAP on your Gmail (or email provider)
|
|
9
|
+
* 2. Set env: EMAIL_USER, EMAIL_PASSWORD, EMAIL_IMAP_HOST, EMAIL_SMTP_HOST
|
|
10
|
+
* 3. For Gmail: use App Password (not regular password)
|
|
11
|
+
*/
|
|
12
|
+
export class EmailChannel extends BaseChannel {
|
|
13
|
+
constructor(config) {
|
|
14
|
+
super("email", config);
|
|
15
|
+
this.transporter = null;
|
|
16
|
+
this.pollTimer = null;
|
|
17
|
+
this.processedIds = new Set();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async start() {
|
|
21
|
+
if (!this.config.user || !this.config.password) {
|
|
22
|
+
console.log(`[Channel:Email] Skipped — no EMAIL_USER/EMAIL_PASSWORD`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const nodemailer = await import("nodemailer");
|
|
27
|
+
|
|
28
|
+
// Set up SMTP transporter for sending replies
|
|
29
|
+
this.transporter = nodemailer.default.createTransport({
|
|
30
|
+
host: this.config.smtp.host,
|
|
31
|
+
port: this.config.smtp.port,
|
|
32
|
+
secure: this.config.smtp.port === 465,
|
|
33
|
+
auth: {
|
|
34
|
+
user: this.config.user,
|
|
35
|
+
pass: this.config.password,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Start polling for new emails
|
|
40
|
+
this.running = true;
|
|
41
|
+
this.pollTimer = setInterval(() => this.pollEmails(), 60000); // every 60s
|
|
42
|
+
console.log(`[Channel:Email] Started (polling every 60s, user: ${this.config.user})`);
|
|
43
|
+
|
|
44
|
+
// Poll immediately on start
|
|
45
|
+
this.pollEmails();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async stop() {
|
|
49
|
+
this.running = false;
|
|
50
|
+
if (this.pollTimer) {
|
|
51
|
+
clearInterval(this.pollTimer);
|
|
52
|
+
this.pollTimer = null;
|
|
53
|
+
}
|
|
54
|
+
console.log(`[Channel:Email] Stopped`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async pollEmails() {
|
|
58
|
+
try {
|
|
59
|
+
const Imap = (await import("imap")).default;
|
|
60
|
+
|
|
61
|
+
const imap = new Imap({
|
|
62
|
+
user: this.config.user,
|
|
63
|
+
password: this.config.password,
|
|
64
|
+
host: this.config.imap.host,
|
|
65
|
+
port: this.config.imap.port,
|
|
66
|
+
tls: true,
|
|
67
|
+
tlsOptions: { rejectUnauthorized: false },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await new Promise((resolve, reject) => {
|
|
71
|
+
imap.once("ready", () => {
|
|
72
|
+
imap.openBox("INBOX", false, (err) => {
|
|
73
|
+
if (err) { reject(err); return; }
|
|
74
|
+
|
|
75
|
+
// Search for unseen emails
|
|
76
|
+
imap.search(["UNSEEN"], (err, results) => {
|
|
77
|
+
if (err) { imap.end(); reject(err); return; }
|
|
78
|
+
if (!results || results.length === 0) { imap.end(); resolve(); return; }
|
|
79
|
+
|
|
80
|
+
console.log(`[Channel:Email] Found ${results.length} new email(s)`);
|
|
81
|
+
|
|
82
|
+
const fetch = imap.fetch(results, { bodies: "", markSeen: true });
|
|
83
|
+
|
|
84
|
+
fetch.on("message", (msg) => {
|
|
85
|
+
let body = "";
|
|
86
|
+
let headers = {};
|
|
87
|
+
|
|
88
|
+
msg.on("body", (stream) => {
|
|
89
|
+
stream.on("data", (chunk) => { body += chunk.toString(); });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
msg.once("attributes", (attrs) => {
|
|
93
|
+
headers = attrs;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
msg.once("end", () => {
|
|
97
|
+
this.processEmail(body, headers);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
fetch.once("end", () => { imap.end(); resolve(); });
|
|
102
|
+
fetch.once("error", (err) => { imap.end(); reject(err); });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
imap.once("error", reject);
|
|
108
|
+
imap.connect();
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.log(`[Channel:Email] Poll error: ${error.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async processEmail(rawEmail, attrs) {
|
|
116
|
+
try {
|
|
117
|
+
// Simple header parsing
|
|
118
|
+
const fromMatch = rawEmail.match(/^From:\s*(.+)$/mi);
|
|
119
|
+
const subjectMatch = rawEmail.match(/^Subject:\s*(.+)$/mi);
|
|
120
|
+
const from = fromMatch ? fromMatch[1].trim() : "unknown";
|
|
121
|
+
const subject = subjectMatch ? subjectMatch[1].trim() : "(no subject)";
|
|
122
|
+
|
|
123
|
+
// Extract email address from "Name <email>" format
|
|
124
|
+
const emailMatch = from.match(/<([^>]+)>/);
|
|
125
|
+
const senderEmail = emailMatch ? emailMatch[1] : from;
|
|
126
|
+
|
|
127
|
+
// Extract body (simplified — takes text after headers)
|
|
128
|
+
const bodyStart = rawEmail.indexOf("\r\n\r\n");
|
|
129
|
+
const emailBody = bodyStart > -1 ? rawEmail.slice(bodyStart + 4).trim() : rawEmail;
|
|
130
|
+
|
|
131
|
+
// Skip if already processed
|
|
132
|
+
const uid = `${senderEmail}-${subject}-${attrs?.uid || Date.now()}`;
|
|
133
|
+
if (this.processedIds.has(uid)) return;
|
|
134
|
+
this.processedIds.add(uid);
|
|
135
|
+
|
|
136
|
+
console.log(`[Channel:Email] Processing: from=${senderEmail} subject="${subject}"`);
|
|
137
|
+
|
|
138
|
+
const taskInput = `Email from: ${senderEmail}\nSubject: ${subject}\n\nBody:\n${emailBody.slice(0, 5000)}`;
|
|
139
|
+
|
|
140
|
+
const task = taskQueue.enqueue({
|
|
141
|
+
input: taskInput,
|
|
142
|
+
channel: "email",
|
|
143
|
+
channelMeta: { senderEmail, subject },
|
|
144
|
+
sessionId: this.getSessionId(senderEmail),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Wait and reply
|
|
148
|
+
const completedTask = await taskQueue.waitForCompletion(task.id);
|
|
149
|
+
if (this.isTaskMerged(completedTask)) return; // absorbed into concurrent session
|
|
150
|
+
const response = completedTask.status === "failed"
|
|
151
|
+
? `Sorry, I encountered an error processing your request: ${completedTask.error}`
|
|
152
|
+
: completedTask.result || "Done.";
|
|
153
|
+
|
|
154
|
+
await this.sendReply({ senderEmail, subject }, response);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.log(`[Channel:Email] Process error: ${error.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async sendReply(channelMeta, text) {
|
|
161
|
+
if (!this.transporter) return;
|
|
162
|
+
|
|
163
|
+
await this.transporter.sendMail({
|
|
164
|
+
from: this.config.user,
|
|
165
|
+
to: channelMeta.senderEmail,
|
|
166
|
+
subject: `Re: ${channelMeta.subject}`,
|
|
167
|
+
text: text,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
console.log(`[Channel:Email] Reply sent to ${channelMeta.senderEmail}`);
|
|
171
|
+
}
|
|
172
|
+
}
|