mercury-agent 0.4.5
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/LICENSE +22 -0
- package/README.md +438 -0
- package/container/Dockerfile +127 -0
- package/container/Dockerfile.base +109 -0
- package/container/Dockerfile.power +17 -0
- package/container/agent-package.json +8 -0
- package/container/build.sh +54 -0
- package/docs/TODOS.md +147 -0
- package/docs/auth/dashboard.md +28 -0
- package/docs/auth/overview.md +109 -0
- package/docs/auth/whatsapp.md +173 -0
- package/docs/configuration.md +54 -0
- package/docs/container-lifecycle.md +349 -0
- package/docs/context-architecture.md +87 -0
- package/docs/deployment.md +199 -0
- package/docs/extensions.md +375 -0
- package/docs/graceful-shutdown.md +62 -0
- package/docs/kb-distillation.md +77 -0
- package/docs/media/overview.md +140 -0
- package/docs/media/whatsapp.md +171 -0
- package/docs/memory.md +137 -0
- package/docs/permissions.md +217 -0
- package/docs/pipeline.md +228 -0
- package/docs/prd-chat-memory.md +76 -0
- package/docs/prd-config-load.md +82 -0
- package/docs/rate-limiting.md +166 -0
- package/docs/scheduler.md +288 -0
- package/docs/setup-discord.md +100 -0
- package/docs/setup-slack.md +119 -0
- package/docs/setup-whatsapp.md +94 -0
- package/docs/subagents.md +166 -0
- package/docs/web-search.md +62 -0
- package/examples/extensions/README.md +12 -0
- package/examples/extensions/charts/index.ts +13 -0
- package/examples/extensions/charts/skill/SKILL.md +98 -0
- package/examples/extensions/gws/README.md +52 -0
- package/examples/extensions/gws/index.ts +106 -0
- package/examples/extensions/gws/skill/SKILL.md +57 -0
- package/examples/extensions/gws/skill/references/calendar.md +101 -0
- package/examples/extensions/gws/skill/references/docs.md +65 -0
- package/examples/extensions/gws/skill/references/drive.md +79 -0
- package/examples/extensions/gws/skill/references/gmail.md +85 -0
- package/examples/extensions/gws/skill/references/sheets.md +60 -0
- package/examples/extensions/napkin/index.ts +821 -0
- package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
- package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
- package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
- package/examples/extensions/napkin/skill/SKILL.md +728 -0
- package/examples/extensions/pdf/index.ts +23 -0
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
- package/examples/extensions/pdf/skill/SKILL.md +314 -0
- package/examples/extensions/pdf/skill/forms.md +294 -0
- package/examples/extensions/pdf/skill/reference.md +612 -0
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/examples/extensions/permission-guard/index.ts +65 -0
- package/examples/extensions/pinchtab/index.ts +199 -0
- package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
- package/examples/extensions/pinchtab/skill/references/api.md +297 -0
- package/examples/extensions/pinchtab/skill/references/env.md +45 -0
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
- package/examples/extensions/tradestation/host/refresh.ts +102 -0
- package/examples/extensions/tradestation/index.ts +153 -0
- package/examples/extensions/tradestation/skill/SKILL.md +67 -0
- package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
- package/examples/extensions/voice-synth/index.ts +94 -0
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
- package/examples/extensions/voice-transcribe/index.ts +381 -0
- package/examples/extensions/voice-transcribe/requirements.txt +8 -0
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
- package/examples/extensions/web-search/index.ts +22 -0
- package/examples/extensions/web-search/skill/SKILL.md +114 -0
- package/examples/extensions/web-search/skill/references/apartments.md +178 -0
- package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
- package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
- package/examples/extensions/web-search/skill/references/flights.md +133 -0
- package/examples/extensions/web-search/skill/references/hotels.md +148 -0
- package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
- package/examples/extensions/yahoo-mail/cli/package.json +13 -0
- package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
- package/examples/extensions/yahoo-mail/index.ts +57 -0
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
- package/package.json +106 -0
- package/resources/agents/explore.md +50 -0
- package/resources/agents/worker.md +24 -0
- package/resources/builtin-extensions.txt +3 -0
- package/resources/connection-env-vars.json +25 -0
- package/resources/extensions/.gitkeep +0 -0
- package/resources/pi-extensions/subagent/agents.ts +126 -0
- package/resources/pi-extensions/subagent/index.ts +964 -0
- package/resources/profiles/coding/AGENTS.md +43 -0
- package/resources/profiles/coding/mercury-profile.yaml +15 -0
- package/resources/profiles/general/AGENTS.md +31 -0
- package/resources/profiles/general/mercury-profile.yaml +15 -0
- package/resources/profiles/research/AGENTS.md +40 -0
- package/resources/profiles/research/mercury-profile.yaml +15 -0
- package/resources/skills/config/SKILL.md +25 -0
- package/resources/skills/context/SKILL.md +33 -0
- package/resources/skills/conversation-recap/SKILL.md +19 -0
- package/resources/skills/media/SKILL.md +27 -0
- package/resources/skills/mutes/SKILL.md +31 -0
- package/resources/skills/permissions/SKILL.md +19 -0
- package/resources/skills/preferences/SKILL.md +31 -0
- package/resources/skills/recall/SKILL.md +24 -0
- package/resources/skills/roles/SKILL.md +18 -0
- package/resources/skills/spaces/SKILL.md +18 -0
- package/resources/skills/tasks/SKILL.md +45 -0
- package/resources/templates/AGENTS.md +157 -0
- package/resources/templates/env.template +34 -0
- package/resources/templates/mercury.example.yaml +75 -0
- package/src/adapters/discord-native.ts +534 -0
- package/src/adapters/discord.ts +38 -0
- package/src/adapters/setup.ts +89 -0
- package/src/adapters/slack.ts +9 -0
- package/src/adapters/whatsapp-media.ts +337 -0
- package/src/adapters/whatsapp.ts +629 -0
- package/src/agent/api-socket.ts +127 -0
- package/src/agent/container-entry.ts +967 -0
- package/src/agent/container-error.ts +49 -0
- package/src/agent/container-runner.ts +1272 -0
- package/src/agent/model-capabilities-core.ts +23 -0
- package/src/agent/model-capabilities.ts +231 -0
- package/src/agent/pi-failure-class.ts +83 -0
- package/src/agent/pi-jsonl-parser.ts +306 -0
- package/src/agent/preferences-prompt.ts +20 -0
- package/src/agent/user-error-messages.ts +78 -0
- package/src/bridges/discord.ts +171 -0
- package/src/bridges/slack.ts +177 -0
- package/src/bridges/teams.ts +160 -0
- package/src/bridges/telegram.ts +571 -0
- package/src/bridges/whatsapp.ts +290 -0
- package/src/chat-shim.ts +259 -0
- package/src/cli/mercury.ts +2508 -0
- package/src/cli/mrctl-http.ts +27 -0
- package/src/cli/mrctl.ts +611 -0
- package/src/cli/whatsapp-auth.ts +260 -0
- package/src/config-file.ts +397 -0
- package/src/config-model-chain.ts +30 -0
- package/src/config.ts +316 -0
- package/src/core/api-types.ts +58 -0
- package/src/core/api.ts +105 -0
- package/src/core/commands.ts +76 -0
- package/src/core/conversation.ts +47 -0
- package/src/core/handler.ts +206 -0
- package/src/core/media.ts +200 -0
- package/src/core/mute-duration.ts +22 -0
- package/src/core/outbox.ts +76 -0
- package/src/core/permissions.ts +192 -0
- package/src/core/profiles.ts +245 -0
- package/src/core/rate-limiter.ts +127 -0
- package/src/core/router.ts +191 -0
- package/src/core/routes/chat.ts +172 -0
- package/src/core/routes/config-builtin.ts +107 -0
- package/src/core/routes/config.ts +81 -0
- package/src/core/routes/connections.ts +190 -0
- package/src/core/routes/console.ts +668 -0
- package/src/core/routes/control.ts +46 -0
- package/src/core/routes/conversations.ts +66 -0
- package/src/core/routes/dashboard.ts +2491 -0
- package/src/core/routes/extensions.ts +37 -0
- package/src/core/routes/index.ts +14 -0
- package/src/core/routes/media.ts +72 -0
- package/src/core/routes/messages.ts +37 -0
- package/src/core/routes/mutes.ts +89 -0
- package/src/core/routes/prefs.ts +95 -0
- package/src/core/routes/roles.ts +125 -0
- package/src/core/routes/spaces.ts +60 -0
- package/src/core/routes/storage.ts +126 -0
- package/src/core/routes/tasks.ts +189 -0
- package/src/core/routes/tradestation.ts +268 -0
- package/src/core/routes/tts.ts +51 -0
- package/src/core/runtime.ts +1140 -0
- package/src/core/space-queue.ts +103 -0
- package/src/core/storage-cleanup.ts +140 -0
- package/src/core/storage-guard.ts +24 -0
- package/src/core/task-scheduler.ts +132 -0
- package/src/core/telegram-format.ts +178 -0
- package/src/core/trigger.ts +142 -0
- package/src/dashboard/index.html +729 -0
- package/src/dashboard/tokens.css +53 -0
- package/src/extensions/api.ts +252 -0
- package/src/extensions/catalog.ts +117 -0
- package/src/extensions/config-registry.ts +83 -0
- package/src/extensions/context.ts +36 -0
- package/src/extensions/hooks.ts +156 -0
- package/src/extensions/image-builder.ts +617 -0
- package/src/extensions/installer.ts +306 -0
- package/src/extensions/jobs.ts +122 -0
- package/src/extensions/loader.ts +271 -0
- package/src/extensions/permission-guard.ts +52 -0
- package/src/extensions/reserved.ts +28 -0
- package/src/extensions/skills.ts +123 -0
- package/src/extensions/types.ts +462 -0
- package/src/logger.ts +174 -0
- package/src/main.ts +586 -0
- package/src/server.ts +391 -0
- package/src/storage/db.ts +1624 -0
- package/src/storage/memory.ts +45 -0
- package/src/storage/pi-auth.ts +95 -0
- package/src/text/markdown.ts +117 -0
- package/src/text/rtl.ts +38 -0
- package/src/tradestation/host-api.ts +77 -0
- package/src/tradestation/pending-orders.ts +69 -0
- package/src/tts/azure.ts +52 -0
- package/src/tts/google.ts +128 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/language.ts +20 -0
- package/src/tts/synthesize.ts +133 -0
- package/src/types.ts +295 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { Adapter, Message } from "chat";
|
|
2
|
+
import { telegramInboundLooksLikeMedia } from "../bridges/telegram.js";
|
|
3
|
+
import type { AppConfig } from "../config.js";
|
|
4
|
+
import { logger } from "../logger.js";
|
|
5
|
+
import type { NormalizeContext, PlatformBridge } from "../types.js";
|
|
6
|
+
import { inferConversationKind, resolveConversation } from "./conversation.js";
|
|
7
|
+
import type { MercuryCoreRuntime } from "./runtime.js";
|
|
8
|
+
import { loadTriggerConfig, matchTrigger } from "./trigger.js";
|
|
9
|
+
|
|
10
|
+
export interface MessageHandlerOptions {
|
|
11
|
+
bridge: PlatformBridge;
|
|
12
|
+
core: MercuryCoreRuntime;
|
|
13
|
+
config: AppConfig;
|
|
14
|
+
ctx: NormalizeContext;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createMessageHandler(opts: MessageHandlerOptions) {
|
|
18
|
+
const { bridge, core, config, ctx } = opts;
|
|
19
|
+
const defaultPatterns = config.triggerPatterns
|
|
20
|
+
.split(",")
|
|
21
|
+
.map((s: string) => s.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
|
|
24
|
+
return async (
|
|
25
|
+
adapter: Adapter,
|
|
26
|
+
threadId: string,
|
|
27
|
+
message: Message,
|
|
28
|
+
): Promise<void> => {
|
|
29
|
+
try {
|
|
30
|
+
logger.debug("Incoming message", {
|
|
31
|
+
adapter: adapter.name,
|
|
32
|
+
threadId,
|
|
33
|
+
textPreview: String(message.text ?? "").slice(0, 80),
|
|
34
|
+
isMe: message.author.isMe,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (message.author.isMe) return;
|
|
38
|
+
|
|
39
|
+
const text = message.text.trim();
|
|
40
|
+
const looksLikeMedia =
|
|
41
|
+
bridge.platform === "telegram"
|
|
42
|
+
? telegramInboundLooksLikeMedia(message)
|
|
43
|
+
: (message.attachments?.length ?? 0) > 0;
|
|
44
|
+
if (!text && !looksLikeMedia) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { externalId, isDM } = bridge.parseThread(threadId);
|
|
49
|
+
const kind = inferConversationKind(bridge.platform, externalId, isDM);
|
|
50
|
+
const resolution = resolveConversation(
|
|
51
|
+
core.db,
|
|
52
|
+
bridge.platform,
|
|
53
|
+
externalId,
|
|
54
|
+
kind,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (!resolution) {
|
|
58
|
+
logger.debug("Message ignored: conversation not linked to a space", {
|
|
59
|
+
platform: bridge.platform,
|
|
60
|
+
externalId,
|
|
61
|
+
kind,
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { spaceId } = resolution;
|
|
67
|
+
|
|
68
|
+
const triggerConfig = loadTriggerConfig(core.db, spaceId, {
|
|
69
|
+
patterns: defaultPatterns,
|
|
70
|
+
match: config.triggerMatch,
|
|
71
|
+
});
|
|
72
|
+
const hasAttachments = looksLikeMedia;
|
|
73
|
+
const triggerResult = matchTrigger(
|
|
74
|
+
text,
|
|
75
|
+
triggerConfig,
|
|
76
|
+
isDM,
|
|
77
|
+
hasAttachments,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (triggerResult.matched) {
|
|
81
|
+
try {
|
|
82
|
+
await adapter.startTyping(threadId);
|
|
83
|
+
} catch {
|
|
84
|
+
// Best-effort typing indicator
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ingress = await bridge.normalize(threadId, message, ctx, spaceId);
|
|
89
|
+
if (!ingress) return;
|
|
90
|
+
|
|
91
|
+
logger.info(
|
|
92
|
+
`Message from: ${ingress.callerId}${ingress.authorName ? ` (${ingress.authorName})` : ""}`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (ingress.isReplyToBot && !isDM && !triggerResult.matched) {
|
|
96
|
+
try {
|
|
97
|
+
await adapter.startTyping(threadId);
|
|
98
|
+
} catch {
|
|
99
|
+
// Best-effort typing indicator
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const startTime = Date.now();
|
|
104
|
+
let lastStatusMessageId: string | undefined;
|
|
105
|
+
let hadStatusMessage = false;
|
|
106
|
+
|
|
107
|
+
const heartbeat = setInterval(() => {
|
|
108
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
109
|
+
const statusText = `⏳ Processing… (${elapsed}s)`;
|
|
110
|
+
const currentId = lastStatusMessageId;
|
|
111
|
+
|
|
112
|
+
if (currentId && bridge.editMessage) {
|
|
113
|
+
bridge
|
|
114
|
+
.editMessage(threadId, currentId, statusText)
|
|
115
|
+
.then((ok) => {
|
|
116
|
+
if (!ok) {
|
|
117
|
+
// Edit failed — fall back to delete+send
|
|
118
|
+
bridge
|
|
119
|
+
.sendReply(threadId, statusText)
|
|
120
|
+
.then(async (id) => {
|
|
121
|
+
await bridge
|
|
122
|
+
.deleteMessages?.(threadId, [currentId])
|
|
123
|
+
.catch(() => {});
|
|
124
|
+
if (id) lastStatusMessageId = id;
|
|
125
|
+
})
|
|
126
|
+
.catch(() => {});
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
.catch(() => {});
|
|
130
|
+
} else {
|
|
131
|
+
const prevId = lastStatusMessageId;
|
|
132
|
+
bridge
|
|
133
|
+
.sendReply(threadId, statusText)
|
|
134
|
+
.then(async (id) => {
|
|
135
|
+
if (prevId) {
|
|
136
|
+
await bridge
|
|
137
|
+
.deleteMessages?.(threadId, [prevId])
|
|
138
|
+
.catch(() => {});
|
|
139
|
+
}
|
|
140
|
+
if (id) {
|
|
141
|
+
lastStatusMessageId = id;
|
|
142
|
+
hadStatusMessage = true;
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
.catch(() => {});
|
|
146
|
+
}
|
|
147
|
+
}, 30_000);
|
|
148
|
+
|
|
149
|
+
let result: Awaited<ReturnType<typeof core.handleRawInput>>;
|
|
150
|
+
try {
|
|
151
|
+
result = await core.handleRawInput(ingress, "chat-sdk");
|
|
152
|
+
} finally {
|
|
153
|
+
clearInterval(heartbeat);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (lastStatusMessageId) {
|
|
157
|
+
await bridge
|
|
158
|
+
.deleteMessages?.(threadId, [lastStatusMessageId])
|
|
159
|
+
.catch(() => {});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (result.type === "ignore") return;
|
|
163
|
+
|
|
164
|
+
if (result.type === "denied") {
|
|
165
|
+
await bridge.sendReply(threadId, result.reason);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (result.result) {
|
|
170
|
+
const { reply, files, assistantMessageId } = result.result;
|
|
171
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
172
|
+
const finalReply =
|
|
173
|
+
hadStatusMessage && reply
|
|
174
|
+
? `${reply}\n\n_(responded in ${elapsed}s)_`
|
|
175
|
+
: reply;
|
|
176
|
+
if (finalReply || files.length > 0) {
|
|
177
|
+
const sentPlatformId = await bridge.sendReply(
|
|
178
|
+
threadId,
|
|
179
|
+
finalReply,
|
|
180
|
+
files.length > 0 ? files : undefined,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Record the platform message ID mapping for the bot's outbound message
|
|
184
|
+
if (
|
|
185
|
+
sentPlatformId &&
|
|
186
|
+
assistantMessageId &&
|
|
187
|
+
ingress.conversationExternalId
|
|
188
|
+
) {
|
|
189
|
+
core.recordOutboundPlatformId(
|
|
190
|
+
assistantMessageId,
|
|
191
|
+
bridge.platform,
|
|
192
|
+
ingress.conversationExternalId,
|
|
193
|
+
sentPlatformId,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
logger.error("Message handler error", {
|
|
200
|
+
platform: bridge.platform,
|
|
201
|
+
threadId,
|
|
202
|
+
error: err instanceof Error ? err.message : String(err),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared media utilities for ingress/egress pipeline.
|
|
3
|
+
*
|
|
4
|
+
* - MIME detection (filename → MIME, MIME → extension, MIME → MediaType)
|
|
5
|
+
* - Generic URL-based media downloader (for Discord, Slack attachments)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { logger } from "../logger.js";
|
|
11
|
+
import type { MediaType, MessageAttachment } from "../types.js";
|
|
12
|
+
|
|
13
|
+
// ─── MIME Maps ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Extension → MIME type */
|
|
16
|
+
const EXT_TO_MIME: Record<string, string> = {
|
|
17
|
+
// Images
|
|
18
|
+
jpg: "image/jpeg",
|
|
19
|
+
jpeg: "image/jpeg",
|
|
20
|
+
png: "image/png",
|
|
21
|
+
gif: "image/gif",
|
|
22
|
+
webp: "image/webp",
|
|
23
|
+
svg: "image/svg+xml",
|
|
24
|
+
// Audio
|
|
25
|
+
ogg: "audio/ogg",
|
|
26
|
+
mp3: "audio/mpeg",
|
|
27
|
+
m4a: "audio/mp4",
|
|
28
|
+
aac: "audio/aac",
|
|
29
|
+
wav: "audio/wav",
|
|
30
|
+
// Video
|
|
31
|
+
mp4: "video/mp4",
|
|
32
|
+
"3gp": "video/3gpp",
|
|
33
|
+
webm: "video/webm",
|
|
34
|
+
// Documents
|
|
35
|
+
pdf: "application/pdf",
|
|
36
|
+
txt: "text/plain",
|
|
37
|
+
csv: "text/csv",
|
|
38
|
+
json: "application/json",
|
|
39
|
+
html: "text/html",
|
|
40
|
+
md: "text/markdown",
|
|
41
|
+
doc: "application/msword",
|
|
42
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
43
|
+
xls: "application/vnd.ms-excel",
|
|
44
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** MIME type → extension */
|
|
48
|
+
const MIME_TO_EXT: Record<string, string> = {
|
|
49
|
+
"image/jpeg": "jpg",
|
|
50
|
+
"image/png": "png",
|
|
51
|
+
"image/gif": "gif",
|
|
52
|
+
"image/webp": "webp",
|
|
53
|
+
"image/svg+xml": "svg",
|
|
54
|
+
"audio/ogg": "ogg",
|
|
55
|
+
"audio/ogg; codecs=opus": "ogg",
|
|
56
|
+
"audio/mpeg": "mp3",
|
|
57
|
+
"audio/mp4": "m4a",
|
|
58
|
+
"audio/aac": "aac",
|
|
59
|
+
"audio/wav": "wav",
|
|
60
|
+
"video/mp4": "mp4",
|
|
61
|
+
"video/3gpp": "3gp",
|
|
62
|
+
"video/webm": "webm",
|
|
63
|
+
"application/pdf": "pdf",
|
|
64
|
+
"text/plain": "txt",
|
|
65
|
+
"text/csv": "csv",
|
|
66
|
+
"application/json": "json",
|
|
67
|
+
"text/html": "html",
|
|
68
|
+
"text/markdown": "md",
|
|
69
|
+
"application/msword": "doc",
|
|
70
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
|
71
|
+
"docx",
|
|
72
|
+
"application/vnd.ms-excel": "xls",
|
|
73
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// ─── MIME Utilities ─────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/** Detect MIME type from filename extension. */
|
|
79
|
+
export function extToMime(filename: string): string {
|
|
80
|
+
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
81
|
+
return EXT_TO_MIME[ext] ?? "application/octet-stream";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Get file extension from MIME type. Handles MIME params (e.g., "audio/ogg; codecs=opus"). */
|
|
85
|
+
export function mimeToExt(mimeType: string): string {
|
|
86
|
+
const baseMime = mimeType.split(";")[0].trim();
|
|
87
|
+
return MIME_TO_EXT[baseMime] ?? MIME_TO_EXT[mimeType] ?? "bin";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Classify MIME type into MediaType. */
|
|
91
|
+
export function mimeToMediaType(mimeType: string): MediaType {
|
|
92
|
+
const base = mimeType.split(";")[0].trim();
|
|
93
|
+
if (base.startsWith("image/")) return "image";
|
|
94
|
+
if (base.startsWith("video/")) return "video";
|
|
95
|
+
if (base.startsWith("audio/")) return "audio";
|
|
96
|
+
return "document";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── URL-based Media Downloader ─────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Download a file from a URL to a local directory.
|
|
103
|
+
*
|
|
104
|
+
* Used by Discord and Slack bridges to fetch attachments to workspace inbox/.
|
|
105
|
+
* Returns a MessageAttachment on success, null if skipped or failed.
|
|
106
|
+
*/
|
|
107
|
+
export async function downloadMediaFromUrl(
|
|
108
|
+
url: string,
|
|
109
|
+
options: {
|
|
110
|
+
type: MediaType;
|
|
111
|
+
mimeType: string;
|
|
112
|
+
filename?: string;
|
|
113
|
+
expectedSizeBytes?: number;
|
|
114
|
+
maxSizeBytes: number;
|
|
115
|
+
outputDir: string;
|
|
116
|
+
headers?: Record<string, string>;
|
|
117
|
+
},
|
|
118
|
+
): Promise<MessageAttachment | null> {
|
|
119
|
+
const { maxSizeBytes, outputDir, headers } = options;
|
|
120
|
+
|
|
121
|
+
// Check expected size before downloading
|
|
122
|
+
if (options.expectedSizeBytes && options.expectedSizeBytes > maxSizeBytes) {
|
|
123
|
+
logger.warn("Skipping large media file", {
|
|
124
|
+
url: url.slice(0, 100),
|
|
125
|
+
type: options.type,
|
|
126
|
+
sizeBytes: options.expectedSizeBytes,
|
|
127
|
+
maxBytes: maxSizeBytes,
|
|
128
|
+
});
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const response = await fetch(url, {
|
|
134
|
+
headers: headers ?? {},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
logger.error("Media download failed", {
|
|
139
|
+
url: url.slice(0, 100),
|
|
140
|
+
status: response.status,
|
|
141
|
+
});
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check Content-Length header before buffering
|
|
146
|
+
const contentLength = response.headers.get("content-length");
|
|
147
|
+
if (contentLength && Number.parseInt(contentLength, 10) > maxSizeBytes) {
|
|
148
|
+
logger.warn("Media download exceeds size limit", {
|
|
149
|
+
url: url.slice(0, 100),
|
|
150
|
+
sizeBytes: Number.parseInt(contentLength, 10),
|
|
151
|
+
maxBytes: maxSizeBytes,
|
|
152
|
+
});
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
157
|
+
|
|
158
|
+
// Check actual size after download
|
|
159
|
+
if (buffer.length > maxSizeBytes) {
|
|
160
|
+
logger.warn("Downloaded media exceeds size limit, discarding", {
|
|
161
|
+
sizeBytes: buffer.length,
|
|
162
|
+
maxBytes: maxSizeBytes,
|
|
163
|
+
});
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Ensure output directory exists
|
|
168
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
169
|
+
|
|
170
|
+
// Generate filename: {timestamp}-{original} or {timestamp}-{type}.{ext}
|
|
171
|
+
const ext = mimeToExt(options.mimeType);
|
|
172
|
+
const filename = options.filename
|
|
173
|
+
? `${Date.now()}-${options.filename}`
|
|
174
|
+
: `${Date.now()}-${options.type}.${ext}`;
|
|
175
|
+
|
|
176
|
+
const filePath = path.join(outputDir, filename);
|
|
177
|
+
fs.writeFileSync(filePath, buffer);
|
|
178
|
+
|
|
179
|
+
logger.info("Downloaded media", {
|
|
180
|
+
type: options.type,
|
|
181
|
+
mimeType: options.mimeType,
|
|
182
|
+
sizeBytes: buffer.length,
|
|
183
|
+
path: filePath,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
path: filePath,
|
|
188
|
+
type: options.type,
|
|
189
|
+
mimeType: options.mimeType,
|
|
190
|
+
filename: options.filename,
|
|
191
|
+
sizeBytes: buffer.length,
|
|
192
|
+
};
|
|
193
|
+
} catch (error) {
|
|
194
|
+
logger.error("Failed to download media", {
|
|
195
|
+
url: url.slice(0, 100),
|
|
196
|
+
error: error instanceof Error ? error.message : String(error),
|
|
197
|
+
});
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a duration string like "10m", "1h", "24h", "7d" into milliseconds.
|
|
3
|
+
*/
|
|
4
|
+
export function parseMuteDuration(input: string): number | null {
|
|
5
|
+
const match = input.match(/^(\d+)\s*(m|min|h|hr|d|day)s?$/i);
|
|
6
|
+
if (!match) return null;
|
|
7
|
+
const value = Number.parseInt(match[1], 10);
|
|
8
|
+
const unit = match[2].toLowerCase();
|
|
9
|
+
switch (unit) {
|
|
10
|
+
case "m":
|
|
11
|
+
case "min":
|
|
12
|
+
return value * 60 * 1000;
|
|
13
|
+
case "h":
|
|
14
|
+
case "hr":
|
|
15
|
+
return value * 60 * 60 * 1000;
|
|
16
|
+
case "d":
|
|
17
|
+
case "day":
|
|
18
|
+
return value * 24 * 60 * 60 * 1000;
|
|
19
|
+
default:
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { logger } from "../logger.js";
|
|
4
|
+
import type { EgressFile } from "../types.js";
|
|
5
|
+
import { extToMime } from "./media.js";
|
|
6
|
+
|
|
7
|
+
/** Default max file size for outbox files (25 MB) */
|
|
8
|
+
const DEFAULT_MAX_FILE_SIZE = 25 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Scan outbox/ for files created or modified during a container run.
|
|
12
|
+
*
|
|
13
|
+
* Files with mtime >= startTimeMs are considered new or modified.
|
|
14
|
+
* Skips dotfiles, directories, and files exceeding maxSizeBytes.
|
|
15
|
+
* Non-recursive (one level only).
|
|
16
|
+
*/
|
|
17
|
+
export function scanOutbox(
|
|
18
|
+
workspacePath: string,
|
|
19
|
+
startTimeMs: number,
|
|
20
|
+
maxSizeBytes = DEFAULT_MAX_FILE_SIZE,
|
|
21
|
+
): EgressFile[] {
|
|
22
|
+
const outboxDir = path.join(workspacePath, "outbox");
|
|
23
|
+
|
|
24
|
+
if (!fs.existsSync(outboxDir)) return [];
|
|
25
|
+
|
|
26
|
+
const files: EgressFile[] = [];
|
|
27
|
+
|
|
28
|
+
let entries: fs.Dirent[];
|
|
29
|
+
try {
|
|
30
|
+
entries = fs.readdirSync(outboxDir, { withFileTypes: true });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
logger.warn("Failed to read outbox directory", {
|
|
33
|
+
outboxDir,
|
|
34
|
+
error: error instanceof Error ? error.message : String(error),
|
|
35
|
+
});
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (!entry.isFile()) continue;
|
|
41
|
+
if (entry.name.startsWith(".")) continue;
|
|
42
|
+
|
|
43
|
+
const filePath = path.join(outboxDir, entry.name);
|
|
44
|
+
|
|
45
|
+
let stat: fs.Stats;
|
|
46
|
+
try {
|
|
47
|
+
stat = fs.statSync(filePath);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
logger.warn("Failed to stat outbox file, skipping", {
|
|
50
|
+
path: filePath,
|
|
51
|
+
error: error instanceof Error ? error.message : String(error),
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (stat.mtimeMs < startTimeMs) continue;
|
|
57
|
+
|
|
58
|
+
if (stat.size > maxSizeBytes) {
|
|
59
|
+
logger.warn("Outbox file exceeds max size, skipping", {
|
|
60
|
+
path: filePath,
|
|
61
|
+
sizeBytes: stat.size,
|
|
62
|
+
maxSizeBytes,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
files.push({
|
|
68
|
+
path: filePath,
|
|
69
|
+
filename: entry.name,
|
|
70
|
+
mimeType: extToMime(entry.name),
|
|
71
|
+
sizeBytes: stat.size,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return files;
|
|
76
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Db } from "../storage/db.js";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Built-in permissions (static, cannot be overridden)
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const BUILT_IN_PERMISSIONS = new Set([
|
|
8
|
+
"prompt",
|
|
9
|
+
"stop",
|
|
10
|
+
"compact",
|
|
11
|
+
"clear",
|
|
12
|
+
"tasks.list",
|
|
13
|
+
"tasks.create",
|
|
14
|
+
"tasks.pause",
|
|
15
|
+
"tasks.resume",
|
|
16
|
+
"tasks.delete",
|
|
17
|
+
"config.get",
|
|
18
|
+
"config.set",
|
|
19
|
+
"prefs.get",
|
|
20
|
+
"prefs.set",
|
|
21
|
+
"roles.list",
|
|
22
|
+
"roles.grant",
|
|
23
|
+
"roles.revoke",
|
|
24
|
+
"permissions.get",
|
|
25
|
+
"permissions.set",
|
|
26
|
+
"spaces.list",
|
|
27
|
+
"spaces.rename",
|
|
28
|
+
"spaces.delete",
|
|
29
|
+
/** Purge inbox/outbox media files. */
|
|
30
|
+
"media.purge",
|
|
31
|
+
/** Host Text-to-Speech (/api/tts); admin-only by default. */
|
|
32
|
+
"tts.synthesize",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Extension-registered permissions (dynamic, added at runtime)
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const registeredPermissions = new Map<string, { defaultRoles: string[] }>();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register a new permission from an extension.
|
|
43
|
+
* Throws if the name collides with a built-in permission.
|
|
44
|
+
*/
|
|
45
|
+
export function registerPermission(
|
|
46
|
+
name: string,
|
|
47
|
+
opts: { defaultRoles: string[] },
|
|
48
|
+
): void {
|
|
49
|
+
if (BUILT_IN_PERMISSIONS.has(name)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Permission "${name}" is a built-in and cannot be overridden`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
registeredPermissions.set(name, opts);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get all valid permission names (built-in + extension-registered).
|
|
59
|
+
*/
|
|
60
|
+
export function getAllPermissions(): string[] {
|
|
61
|
+
return [...BUILT_IN_PERMISSIONS, ...registeredPermissions.keys()];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if a permission name is valid (built-in or registered).
|
|
66
|
+
*/
|
|
67
|
+
export function isValidPermission(name: string): boolean {
|
|
68
|
+
return BUILT_IN_PERMISSIONS.has(name) || registeredPermissions.has(name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Clear all registered extension permissions. For test isolation only.
|
|
73
|
+
*/
|
|
74
|
+
export function resetPermissions(): void {
|
|
75
|
+
registeredPermissions.clear();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Seeded groups tracking
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Tracks which groups have had admins seeded to avoid redundant DB calls.
|
|
84
|
+
* Exported for test isolation (tests should clear this in beforeEach).
|
|
85
|
+
*/
|
|
86
|
+
export const seededSpaces = new Set<string>();
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// System callers
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* System callers — these identities get full permissions without DB lookup.
|
|
94
|
+
* Used for scheduled tasks, internal system calls, etc.
|
|
95
|
+
*/
|
|
96
|
+
const SYSTEM_CALLERS = new Set(["system"]);
|
|
97
|
+
|
|
98
|
+
export function isSystemCaller(callerId: string): boolean {
|
|
99
|
+
return SYSTEM_CALLERS.has(callerId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Default role permissions
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/** Built-in defaults for the member role */
|
|
107
|
+
const DEFAULT_MEMBER_PERMISSIONS = new Set(["prompt", "prefs.get"]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compute the default permission set for a role, merging built-in defaults
|
|
111
|
+
* with extension-registered defaults.
|
|
112
|
+
*
|
|
113
|
+
* - `admin` and `system` get all permissions (built-in + extension)
|
|
114
|
+
* - `member` gets `prompt`, `prefs.get`, plus any extension permissions that list "member" in defaultRoles
|
|
115
|
+
* - Other roles get extension permissions that list them in defaultRoles
|
|
116
|
+
*/
|
|
117
|
+
function getDefaultPermissions(role: string): Set<string> {
|
|
118
|
+
if (role === "admin" || role === "system") {
|
|
119
|
+
return new Set(getAllPermissions());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const perms = new Set<string>(
|
|
123
|
+
role === "member" ? DEFAULT_MEMBER_PERMISSIONS : [],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
for (const [name, opts] of registeredPermissions) {
|
|
127
|
+
if (opts.defaultRoles.includes(role)) {
|
|
128
|
+
perms.add(name);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return perms;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Permission resolution
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Load the permission set for a role in a group.
|
|
141
|
+
* Checks group_config for "role.<name>.permissions" override,
|
|
142
|
+
* falls back to defaults (built-in + extension).
|
|
143
|
+
*/
|
|
144
|
+
export function getRolePermissions(
|
|
145
|
+
db: Db,
|
|
146
|
+
spaceId: string,
|
|
147
|
+
role: string,
|
|
148
|
+
): Set<string> {
|
|
149
|
+
if (role === "system") return getDefaultPermissions("system");
|
|
150
|
+
|
|
151
|
+
const key = `role.${role}.permissions`;
|
|
152
|
+
const stored = db.getSpaceConfig(spaceId, key);
|
|
153
|
+
|
|
154
|
+
if (stored !== null) {
|
|
155
|
+
const perms = stored
|
|
156
|
+
.split(",")
|
|
157
|
+
.map((s) => s.trim())
|
|
158
|
+
.filter((s) => isValidPermission(s));
|
|
159
|
+
return new Set(perms);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return getDefaultPermissions(role);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function hasPermission(
|
|
166
|
+
db: Db,
|
|
167
|
+
spaceId: string,
|
|
168
|
+
role: string,
|
|
169
|
+
permission: string,
|
|
170
|
+
): boolean {
|
|
171
|
+
return getRolePermissions(db, spaceId, role).has(permission);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function resolveRole(
|
|
175
|
+
db: Db,
|
|
176
|
+
spaceId: string,
|
|
177
|
+
platformUserId: string,
|
|
178
|
+
seededAdmins: string[],
|
|
179
|
+
displayName?: string | null,
|
|
180
|
+
): string {
|
|
181
|
+
// System callers bypass DB entirely
|
|
182
|
+
if (isSystemCaller(platformUserId)) return "system";
|
|
183
|
+
|
|
184
|
+
if (seededAdmins.length > 0 && !seededSpaces.has(spaceId)) {
|
|
185
|
+
db.seedAdmins(spaceId, seededAdmins);
|
|
186
|
+
seededSpaces.add(spaceId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
db.upsertMember(spaceId, platformUserId, displayName);
|
|
190
|
+
|
|
191
|
+
return db.getRole(spaceId, platformUserId) ?? "member";
|
|
192
|
+
}
|