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,329 @@
|
|
|
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 { createReadStream, writeFileSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { join, extname, basename } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Slack Channel — receives messages via Slack Bolt + Socket Mode.
|
|
11
|
+
*
|
|
12
|
+
* Socket Mode means NO public webhook URL needed — works on any machine.
|
|
13
|
+
*
|
|
14
|
+
* Setup:
|
|
15
|
+
* 1. Go to https://api.slack.com/apps → Create New App → From Scratch
|
|
16
|
+
* 2. Under "Socket Mode" → Enable Socket Mode → Generate App-Level Token (xapp-...)
|
|
17
|
+
* → Grant scope: connections:write → copy token as SLACK_APP_TOKEN
|
|
18
|
+
* 3. Under "OAuth & Permissions" → Bot Token Scopes: add:
|
|
19
|
+
* chat:write, channels:history, groups:history, im:history, mpim:history,
|
|
20
|
+
* channels:read, groups:read, im:read, mpim:read, app_mentions:read,
|
|
21
|
+
* reactions:write, reactions:read
|
|
22
|
+
* → Install app to workspace → copy Bot User OAuth Token (xoxb-...) as SLACK_BOT_TOKEN
|
|
23
|
+
* 4. Under "Event Subscriptions" → Enable Events → Subscribe to bot events:
|
|
24
|
+
* message.im, app_mention
|
|
25
|
+
* 5. Set env: SLACK_BOT_TOKEN, SLACK_APP_TOKEN
|
|
26
|
+
*
|
|
27
|
+
* Config:
|
|
28
|
+
* botToken — xoxb-... token
|
|
29
|
+
* appToken — xapp-... token for Socket Mode
|
|
30
|
+
* allowlist — Optional array of Slack user IDs (Uxxxxxxxx) allowed to use the bot
|
|
31
|
+
* model — Optional model override
|
|
32
|
+
*
|
|
33
|
+
* The bot responds to:
|
|
34
|
+
* - Direct messages (message.im)
|
|
35
|
+
* - @mentions in channels (app_mention)
|
|
36
|
+
*/
|
|
37
|
+
export class SlackChannel extends BaseChannel {
|
|
38
|
+
constructor(config) {
|
|
39
|
+
super("slack", config);
|
|
40
|
+
this.app = null;
|
|
41
|
+
this.botUserId = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async start() {
|
|
45
|
+
if (!this.config.botToken || !this.config.appToken) {
|
|
46
|
+
console.log(`[Channel:Slack] Skipped — need SLACK_BOT_TOKEN and SLACK_APP_TOKEN`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const { App } = await import("@slack/bolt");
|
|
51
|
+
|
|
52
|
+
this.app = new App({
|
|
53
|
+
token: this.config.botToken,
|
|
54
|
+
appToken: this.config.appToken,
|
|
55
|
+
socketMode: true,
|
|
56
|
+
logLevel: "error",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Resolve bot's own user ID
|
|
60
|
+
try {
|
|
61
|
+
const authResult = await this.app.client.auth.test({ token: this.config.botToken });
|
|
62
|
+
this.botUserId = authResult.user_id;
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
|
|
65
|
+
// Handle @mentions in channels
|
|
66
|
+
this.app.event("app_mention", async ({ event, say }) => {
|
|
67
|
+
const text = event.text
|
|
68
|
+
.replace(/<@[A-Z0-9]+>/g, "")
|
|
69
|
+
.trim();
|
|
70
|
+
|
|
71
|
+
if (!text) {
|
|
72
|
+
await say({ text: "Yes? Send me a task.", thread_ts: event.ts });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await this._handleMessage({
|
|
77
|
+
text,
|
|
78
|
+
userId: event.user,
|
|
79
|
+
channelId: event.channel,
|
|
80
|
+
threadTs: event.thread_ts || event.ts,
|
|
81
|
+
messageTs: event.ts,
|
|
82
|
+
say,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Handle direct messages
|
|
87
|
+
this.app.message(async ({ message, say }) => {
|
|
88
|
+
if (message.bot_id) return; // Ignore bot messages
|
|
89
|
+
|
|
90
|
+
const hasFiles = message.files && message.files.length > 0;
|
|
91
|
+
if (!message.text && !hasFiles) return;
|
|
92
|
+
|
|
93
|
+
await this._handleMessage({
|
|
94
|
+
text: message.text?.trim() || "",
|
|
95
|
+
files: message.files || [],
|
|
96
|
+
userId: message.user,
|
|
97
|
+
channelId: message.channel,
|
|
98
|
+
threadTs: message.thread_ts || message.ts,
|
|
99
|
+
messageTs: message.ts,
|
|
100
|
+
say,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Approval replies
|
|
105
|
+
eventBus.on("approval:request", async (data) => {
|
|
106
|
+
if (data.channelMeta?.channel !== "slack") return;
|
|
107
|
+
try {
|
|
108
|
+
await this.app.client.chat.postMessage({
|
|
109
|
+
token: this.config.botToken,
|
|
110
|
+
channel: data.channelMeta?.channelId,
|
|
111
|
+
text: data.message,
|
|
112
|
+
thread_ts: data.channelMeta?.threadTs,
|
|
113
|
+
});
|
|
114
|
+
} catch (_) {}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await this.app.start();
|
|
119
|
+
this.running = true;
|
|
120
|
+
console.log(`[Channel:Slack] Started (Socket Mode)`);
|
|
121
|
+
if (this.config.allowlist?.length) {
|
|
122
|
+
console.log(`[Channel:Slack] Allowlist active — ${this.config.allowlist.length} authorized user(s)`);
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.log(`[Channel:Slack] Failed to start: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async _handleMessage({ text, files = [], userId, channelId, threadTs, messageTs, say }) {
|
|
130
|
+
// Allowlist check
|
|
131
|
+
if (!this.isAllowed(userId)) {
|
|
132
|
+
console.log(`[Channel:Slack] Blocked (not in allowlist): ${userId}`);
|
|
133
|
+
await say({ text: "You are not authorized to use this agent.", thread_ts: threadTs });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(`[Channel:Slack] Message from ${userId}: "${text.slice(0, 80)}"${files.length ? ` + ${files.length} file(s)` : ""}`);
|
|
138
|
+
|
|
139
|
+
// React ⏳ to show we're working
|
|
140
|
+
await this._addReaction(channelId, messageTs, "hourglass_flowing_sand");
|
|
141
|
+
|
|
142
|
+
// Build input from text + files
|
|
143
|
+
const inputParts = text ? [text] : [];
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const localPath = await this._downloadFile(file);
|
|
146
|
+
if (!localPath) continue;
|
|
147
|
+
|
|
148
|
+
const mimeType = file.mimetype || "";
|
|
149
|
+
if (mimeType.startsWith("audio/")) {
|
|
150
|
+
console.log(`[Channel:Slack] Audio file — transcribing...`);
|
|
151
|
+
const transcript = await transcribeAudio(localPath);
|
|
152
|
+
inputParts.push(transcript.startsWith("Error:")
|
|
153
|
+
? `[Audio file: ${localPath}]\n${transcript}`
|
|
154
|
+
: `[Audio transcript]: ${transcript}`);
|
|
155
|
+
} else if (mimeType.startsWith("image/")) {
|
|
156
|
+
inputParts.push(`[Photo received: ${localPath}]\nUser caption: ${file.title || text || "Describe and respond to this image."}`);
|
|
157
|
+
} else if (mimeType.startsWith("video/")) {
|
|
158
|
+
inputParts.push(`[Video received: ${localPath}]`);
|
|
159
|
+
} else {
|
|
160
|
+
inputParts.push(`[File received: ${localPath} (${file.name || "document"}, ${_fmtSize(file.size)})]`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const input = inputParts.join("\n");
|
|
165
|
+
|
|
166
|
+
const task = taskQueue.enqueue({
|
|
167
|
+
input,
|
|
168
|
+
channel: "slack",
|
|
169
|
+
channelMeta: { userId, channelId, threadTs, messageTs, channel: "slack" },
|
|
170
|
+
sessionId: this.getSessionId(userId),
|
|
171
|
+
model: this.getModel(),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const completedTask = await taskQueue.waitForCompletion(task.id);
|
|
176
|
+
|
|
177
|
+
// Absorbed into a concurrent session — response already sent via original task
|
|
178
|
+
if (this.isTaskMerged(completedTask)) {
|
|
179
|
+
await this._removeReaction(channelId, messageTs, "hourglass_flowing_sand");
|
|
180
|
+
await this._addReaction(channelId, messageTs, "white_check_mark");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const failed = completedTask.status === "failed";
|
|
185
|
+
const response = failed
|
|
186
|
+
? `Sorry, I encountered an error: ${completedTask.error}`
|
|
187
|
+
: completedTask.result || "Done.";
|
|
188
|
+
|
|
189
|
+
// Swap ⏳ for ✅ or ❌
|
|
190
|
+
await this._removeReaction(channelId, messageTs, "hourglass_flowing_sand");
|
|
191
|
+
await this._addReaction(channelId, messageTs, failed ? "x" : "white_check_mark");
|
|
192
|
+
|
|
193
|
+
// Reply in thread to keep conversations clean
|
|
194
|
+
const chunks = splitMessage(response, 3800);
|
|
195
|
+
for (const chunk of chunks) {
|
|
196
|
+
await say({ text: chunk, thread_ts: threadTs });
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.error(`[Channel:Slack] Error:`, error.message);
|
|
200
|
+
await this._removeReaction(channelId, messageTs, "hourglass_flowing_sand");
|
|
201
|
+
await this._addReaction(channelId, messageTs, "x");
|
|
202
|
+
await say({ text: "Sorry, something went wrong. Please try again.", thread_ts: threadTs });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async stop() {
|
|
207
|
+
if (this.app) {
|
|
208
|
+
await this.app.stop().catch(() => {});
|
|
209
|
+
this.running = false;
|
|
210
|
+
console.log(`[Channel:Slack] Stopped`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async sendReply(channelMeta, text) {
|
|
215
|
+
if (!this.app) return;
|
|
216
|
+
const chunks = splitMessage(text, 3800);
|
|
217
|
+
for (const chunk of chunks) {
|
|
218
|
+
await this.app.client.chat.postMessage({
|
|
219
|
+
token: this.config.botToken,
|
|
220
|
+
channel: channelMeta.channelId,
|
|
221
|
+
text: chunk,
|
|
222
|
+
thread_ts: channelMeta.threadTs,
|
|
223
|
+
}).catch((err) => console.log(`[Channel:Slack] sendReply error: ${err.message}`));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Add an emoji reaction to a Slack message.
|
|
229
|
+
* @param {string} channelId
|
|
230
|
+
* @param {string} timestamp - message ts
|
|
231
|
+
* @param {string} name - reaction name without colons (e.g. "white_check_mark")
|
|
232
|
+
*/
|
|
233
|
+
async sendReaction(channelMeta, emoji) {
|
|
234
|
+
// Map unicode emoji to Slack reaction names
|
|
235
|
+
const emojiMap = { "✅": "white_check_mark", "❌": "x", "⏳": "hourglass_flowing_sand" };
|
|
236
|
+
const name = emojiMap[emoji] || emoji;
|
|
237
|
+
await this._addReaction(channelMeta.channelId, channelMeta.messageTs, name);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async _addReaction(channelId, timestamp, name) {
|
|
241
|
+
try {
|
|
242
|
+
await this.app.client.reactions.add({
|
|
243
|
+
token: this.config.botToken,
|
|
244
|
+
channel: channelId,
|
|
245
|
+
timestamp,
|
|
246
|
+
name,
|
|
247
|
+
});
|
|
248
|
+
} catch (_) {}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async _removeReaction(channelId, timestamp, name) {
|
|
252
|
+
try {
|
|
253
|
+
await this.app.client.reactions.remove({
|
|
254
|
+
token: this.config.botToken,
|
|
255
|
+
channel: channelId,
|
|
256
|
+
timestamp,
|
|
257
|
+
name,
|
|
258
|
+
});
|
|
259
|
+
} catch (_) {}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Send a local file to a Slack channel.
|
|
264
|
+
*/
|
|
265
|
+
async sendFile(channelMeta, filePath, caption) {
|
|
266
|
+
if (!this.app) return;
|
|
267
|
+
try {
|
|
268
|
+
await this.app.client.filesUploadV2({
|
|
269
|
+
token: this.config.botToken,
|
|
270
|
+
channel_id: channelMeta.channelId,
|
|
271
|
+
file: createReadStream(filePath),
|
|
272
|
+
filename: basename(filePath),
|
|
273
|
+
initial_comment: caption || undefined,
|
|
274
|
+
});
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.log(`[Channel:Slack] sendFile error: ${err.message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Download a Slack file to /tmp (requires bot token for auth).
|
|
282
|
+
*/
|
|
283
|
+
async _downloadFile(file) {
|
|
284
|
+
try {
|
|
285
|
+
const url = file.url_private || file.url_private_download;
|
|
286
|
+
if (!url) return null;
|
|
287
|
+
|
|
288
|
+
const ext = extname(file.name || file.title || "").split("?")[0] || "";
|
|
289
|
+
const tmpDir = join(tmpdir(), "daemora-slack");
|
|
290
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
291
|
+
const filePath = join(tmpDir, `${file.id}${ext}`);
|
|
292
|
+
|
|
293
|
+
const res = await fetch(url, {
|
|
294
|
+
headers: { Authorization: `Bearer ${this.config.botToken}` },
|
|
295
|
+
signal: AbortSignal.timeout(30000),
|
|
296
|
+
});
|
|
297
|
+
if (!res.ok) return null;
|
|
298
|
+
|
|
299
|
+
const buffer = await res.arrayBuffer();
|
|
300
|
+
writeFileSync(filePath, Buffer.from(buffer));
|
|
301
|
+
return filePath;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.log(`[Channel:Slack] File download error: ${err.message}`);
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function _fmtSize(bytes) {
|
|
310
|
+
if (!bytes) return "unknown size";
|
|
311
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
312
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
313
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function splitMessage(text, maxLength) {
|
|
317
|
+
if (text.length <= maxLength) return [text];
|
|
318
|
+
const chunks = [];
|
|
319
|
+
let remaining = text;
|
|
320
|
+
while (remaining.length > 0) {
|
|
321
|
+
if (remaining.length <= maxLength) { chunks.push(remaining); break; }
|
|
322
|
+
let idx = remaining.lastIndexOf("\n", maxLength);
|
|
323
|
+
if (idx < maxLength * 0.5) idx = remaining.lastIndexOf(" ", maxLength);
|
|
324
|
+
if (idx === -1) idx = maxLength;
|
|
325
|
+
chunks.push(remaining.slice(0, idx));
|
|
326
|
+
remaining = remaining.slice(idx).trimStart();
|
|
327
|
+
}
|
|
328
|
+
return chunks;
|
|
329
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { BaseChannel } from "./BaseChannel.js";
|
|
2
|
+
import taskQueue from "../core/TaskQueue.js";
|
|
3
|
+
import { transcribeAudio } from "../tools/transcribeAudio.js";
|
|
4
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { join, extname } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Microsoft Teams Channel — receives messages via Bot Framework v4 (CloudAdapter).
|
|
10
|
+
*
|
|
11
|
+
* Setup:
|
|
12
|
+
* 1. Go to https://portal.azure.com → Create a resource → Azure Bot
|
|
13
|
+
* 2. Set messaging endpoint to: https://your-server/webhooks/teams
|
|
14
|
+
* 3. Under "Configuration" → copy App ID as TEAMS_APP_ID
|
|
15
|
+
* 4. Under "Configuration" → Manage Password → New client secret → copy as TEAMS_APP_PASSWORD
|
|
16
|
+
* 5. In the bot resource → Channels → Add Microsoft Teams
|
|
17
|
+
*
|
|
18
|
+
* Config:
|
|
19
|
+
* appId — Microsoft App ID from Azure Bot registration
|
|
20
|
+
* appPassword — Client secret from Azure AD app registration
|
|
21
|
+
* allowlist — Optional array of Teams user IDs / AAD object IDs
|
|
22
|
+
* model — Optional model override
|
|
23
|
+
*
|
|
24
|
+
* Unlike OpenClaw's 461-line channel with Adaptive Cards and Graph API,
|
|
25
|
+
* this is minimal: text messages + file attachments + proactive reply.
|
|
26
|
+
*
|
|
27
|
+
* Teams has a 5-second webhook timeout, so we ack immediately and
|
|
28
|
+
* deliver the agent reply via adapter.continueConversation().
|
|
29
|
+
*/
|
|
30
|
+
export class TeamsChannel extends BaseChannel {
|
|
31
|
+
constructor(config) {
|
|
32
|
+
super("teams", config);
|
|
33
|
+
this.adapter = null;
|
|
34
|
+
this._conversationRefs = new Map(); // userId → conversationReference
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async start() {
|
|
38
|
+
if (!this.config.appId || !this.config.appPassword) {
|
|
39
|
+
console.log(`[Channel:Teams] Skipped — set TEAMS_APP_ID and TEAMS_APP_PASSWORD`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const {
|
|
44
|
+
CloudAdapter,
|
|
45
|
+
ConfigurationBotFrameworkAuthentication,
|
|
46
|
+
TurnContext,
|
|
47
|
+
} = await import("botbuilder");
|
|
48
|
+
|
|
49
|
+
this._TurnContext = TurnContext;
|
|
50
|
+
|
|
51
|
+
const auth = new ConfigurationBotFrameworkAuthentication({
|
|
52
|
+
MicrosoftAppId: this.config.appId,
|
|
53
|
+
MicrosoftAppPassword: this.config.appPassword,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.adapter = new CloudAdapter(auth);
|
|
57
|
+
|
|
58
|
+
this.adapter.onTurnError = async (context, error) => {
|
|
59
|
+
console.error(`[Channel:Teams] Turn error: ${error.message}`);
|
|
60
|
+
try { await context.sendActivity("Sorry, something went wrong. Please try again."); } catch (_) {}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
this.running = true;
|
|
64
|
+
console.log(`[Channel:Teams] Ready (webhook: POST /webhooks/teams)`);
|
|
65
|
+
if (this.config.allowlist?.length) {
|
|
66
|
+
console.log(`[Channel:Teams] Allowlist active — ${this.config.allowlist.length} authorized user(s)`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Handle inbound webhook from Bot Framework.
|
|
72
|
+
* Called by Express route in index.js.
|
|
73
|
+
* Must respond with HTTP 200 within ~5 seconds, so we ack immediately
|
|
74
|
+
* and deliver the agent reply via proactive messaging.
|
|
75
|
+
*/
|
|
76
|
+
async handleWebhook(req, res) {
|
|
77
|
+
if (!this.adapter) {
|
|
78
|
+
res.status(503).json({ error: "Teams channel not started" });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await this.adapter.process(req, res, async (context) => {
|
|
83
|
+
const type = context.activity.type;
|
|
84
|
+
|
|
85
|
+
// Welcome message when bot is added to a conversation
|
|
86
|
+
if (type === "conversationUpdate") {
|
|
87
|
+
const added = context.activity.membersAdded || [];
|
|
88
|
+
for (const member of added) {
|
|
89
|
+
if (member.id !== context.activity.recipient.id) {
|
|
90
|
+
await context.sendActivity("Hello! I'm Daemora. Send me a message and I'll get to work.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (type !== "message") return;
|
|
97
|
+
|
|
98
|
+
const userId = context.activity.from?.id || "unknown";
|
|
99
|
+
const userName = context.activity.from?.name || "User";
|
|
100
|
+
const channelId = context.activity.channelData?.teamsChannelId || context.activity.conversation?.id;
|
|
101
|
+
const text = (context.activity.text || "").replace(/<at[^>]*>.*?<\/at>/gi, "").trim();
|
|
102
|
+
const attachments = context.activity.attachments || [];
|
|
103
|
+
|
|
104
|
+
// Allowlist check
|
|
105
|
+
if (!this.isAllowed(userId)) {
|
|
106
|
+
await context.sendActivity("You are not authorized to use this agent.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Build input from text + any file attachments
|
|
111
|
+
const inputParts = text ? [text] : [];
|
|
112
|
+
for (const att of attachments) {
|
|
113
|
+
if (att.contentType === "application/vnd.microsoft.teams.file.download.info") {
|
|
114
|
+
const localPath = await this._downloadAttachment(att);
|
|
115
|
+
if (localPath) {
|
|
116
|
+
const ct = att.contentType || "";
|
|
117
|
+
if (ct.includes("audio")) {
|
|
118
|
+
const transcript = await transcribeAudio(localPath);
|
|
119
|
+
inputParts.push(transcript.startsWith("Error:")
|
|
120
|
+
? `[Audio file: ${localPath}]\n${transcript}`
|
|
121
|
+
: `[Audio transcript]: ${transcript}`);
|
|
122
|
+
} else {
|
|
123
|
+
inputParts.push(`[File received: ${localPath}]`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} else if (att.contentUrl) {
|
|
127
|
+
const ext = _extFromContentType(att.contentType || "");
|
|
128
|
+
const localPath = await this._downloadUrl(att.contentUrl, ext, this.config);
|
|
129
|
+
if (localPath) {
|
|
130
|
+
if ((att.contentType || "").startsWith("image/")) {
|
|
131
|
+
inputParts.push(`[Photo received: ${localPath}]${text ? "" : "\nDescribe this image."}`);
|
|
132
|
+
} else {
|
|
133
|
+
inputParts.push(`[File received: ${localPath} (${att.name || "attachment"})]`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (inputParts.length === 0) {
|
|
140
|
+
await context.sendActivity("Send me a message and I'll get to work.");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const input = inputParts.join("\n");
|
|
145
|
+
console.log(`[Channel:Teams] Message from ${userName} (${userId}): "${input.slice(0, 80)}"`);
|
|
146
|
+
|
|
147
|
+
// Save conversation reference for proactive reply later
|
|
148
|
+
const ref = this._TurnContext.getConversationReference(context.activity);
|
|
149
|
+
this._conversationRefs.set(userId, ref);
|
|
150
|
+
|
|
151
|
+
// Ack with typing indicator — keeps Teams from showing "delivery failed"
|
|
152
|
+
await context.sendActivity({ type: "typing" });
|
|
153
|
+
|
|
154
|
+
// Enqueue task and reply proactively (don't await — we must return within 5s)
|
|
155
|
+
const task = taskQueue.enqueue({
|
|
156
|
+
input,
|
|
157
|
+
channel: "teams",
|
|
158
|
+
channelMeta: { userId, channelId, userName, channel: "teams" },
|
|
159
|
+
sessionId: this.getSessionId(userId),
|
|
160
|
+
model: this.getModel(),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Fire-and-forget: wait for completion then deliver via continueConversation
|
|
164
|
+
taskQueue.waitForCompletion(task.id)
|
|
165
|
+
.then(async (completedTask) => {
|
|
166
|
+
if (this.isTaskMerged(completedTask)) return; // absorbed into concurrent session
|
|
167
|
+
const failed = completedTask.status === "failed";
|
|
168
|
+
const response = failed
|
|
169
|
+
? `Sorry, I encountered an error: ${completedTask.error}`
|
|
170
|
+
: completedTask.result || "Done.";
|
|
171
|
+
|
|
172
|
+
await this.adapter.continueConversation(ref, async (proactiveCtx) => {
|
|
173
|
+
const chunks = splitMessage(response, 4000);
|
|
174
|
+
for (const chunk of chunks) {
|
|
175
|
+
await proactiveCtx.sendActivity(chunk);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
})
|
|
179
|
+
.catch((err) => {
|
|
180
|
+
console.error(`[Channel:Teams] Reply error: ${err.message}`);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async stop() {
|
|
186
|
+
this.running = false;
|
|
187
|
+
this._conversationRefs.clear();
|
|
188
|
+
console.log(`[Channel:Teams] Stopped`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async sendReply(channelMeta, text) {
|
|
192
|
+
if (!this.adapter) return;
|
|
193
|
+
const ref = this._conversationRefs.get(channelMeta.userId);
|
|
194
|
+
if (!ref) return;
|
|
195
|
+
try {
|
|
196
|
+
await this.adapter.continueConversation(ref, async (ctx) => {
|
|
197
|
+
const chunks = splitMessage(text, 4000);
|
|
198
|
+
for (const chunk of chunks) { await ctx.sendActivity(chunk); }
|
|
199
|
+
});
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.log(`[Channel:Teams] sendReply error: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async sendFile(channelMeta, filePath, caption) {
|
|
206
|
+
if (!this.adapter) return;
|
|
207
|
+
const ref = this._conversationRefs.get(channelMeta.userId);
|
|
208
|
+
if (!ref) return;
|
|
209
|
+
try {
|
|
210
|
+
// Teams file sending requires SharePoint upload for larger files.
|
|
211
|
+
// For simplicity: send the file path as text with caption.
|
|
212
|
+
// Full file upload requires Graph API + bot permissions — out of scope here.
|
|
213
|
+
const msg = caption
|
|
214
|
+
? `${caption}\n(File: ${filePath})`
|
|
215
|
+
: `File: ${filePath}`;
|
|
216
|
+
await this.adapter.continueConversation(ref, async (ctx) => {
|
|
217
|
+
await ctx.sendActivity(msg);
|
|
218
|
+
});
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.log(`[Channel:Teams] sendFile error: ${err.message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async _downloadUrl(url, ext, cfg) {
|
|
225
|
+
try {
|
|
226
|
+
const headers = {};
|
|
227
|
+
// Teams content URLs may require the bot credentials for auth
|
|
228
|
+
if (cfg?.appId && cfg?.appPassword) {
|
|
229
|
+
const creds = Buffer.from(`${cfg.appId}:${cfg.appPassword}`).toString("base64");
|
|
230
|
+
headers["Authorization"] = `Basic ${creds}`;
|
|
231
|
+
}
|
|
232
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(30000) });
|
|
233
|
+
if (!res.ok) return null;
|
|
234
|
+
const tmpDir = join(tmpdir(), "daemora-teams");
|
|
235
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
236
|
+
const filePath = join(tmpDir, `att-${Date.now()}${ext}`);
|
|
237
|
+
writeFileSync(filePath, Buffer.from(await res.arrayBuffer()));
|
|
238
|
+
return filePath;
|
|
239
|
+
} catch { return null; }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async _downloadAttachment(att) {
|
|
243
|
+
const info = att.content?.downloadUrl;
|
|
244
|
+
if (!info) return null;
|
|
245
|
+
const ext = att.name ? extname(att.name) : "";
|
|
246
|
+
return this._downloadUrl(info, ext, this.config);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function _extFromContentType(ct) {
|
|
251
|
+
const map = {
|
|
252
|
+
"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif",
|
|
253
|
+
"audio/ogg": ".ogg", "audio/mpeg": ".mp3", "video/mp4": ".mp4",
|
|
254
|
+
"application/pdf": ".pdf",
|
|
255
|
+
};
|
|
256
|
+
return map[ct] || "";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function splitMessage(text, maxLength) {
|
|
260
|
+
if (text.length <= maxLength) return [text];
|
|
261
|
+
const chunks = [];
|
|
262
|
+
let remaining = text;
|
|
263
|
+
while (remaining.length > 0) {
|
|
264
|
+
if (remaining.length <= maxLength) { chunks.push(remaining); break; }
|
|
265
|
+
let idx = remaining.lastIndexOf("\n", maxLength);
|
|
266
|
+
if (idx < maxLength * 0.5) idx = remaining.lastIndexOf(" ", maxLength);
|
|
267
|
+
if (idx === -1) idx = maxLength;
|
|
268
|
+
chunks.push(remaining.slice(0, idx));
|
|
269
|
+
remaining = remaining.slice(idx).trimStart();
|
|
270
|
+
}
|
|
271
|
+
return chunks;
|
|
272
|
+
}
|