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,78 @@
|
|
|
1
|
+
export type UserErrorCategory =
|
|
2
|
+
| "auth"
|
|
3
|
+
| "key-limit"
|
|
4
|
+
| "credits"
|
|
5
|
+
| "rate-limit"
|
|
6
|
+
| "server-error"
|
|
7
|
+
| "generic";
|
|
8
|
+
|
|
9
|
+
const AUTH_RE =
|
|
10
|
+
/\b401\b|\b403\b|invalid\s+api\s+key|incorrect\s+api\s+key|authentication\s+failed|invalid\s+authentication|unauthorized|access\s+denied/i;
|
|
11
|
+
|
|
12
|
+
const KEY_LIMIT_RE = /quota|billing|usage\s+limit|spending\s+limit/i;
|
|
13
|
+
|
|
14
|
+
const CREDITS_RE =
|
|
15
|
+
/\b402\b|insufficient\s+credits?|not\s+enough\s+credits?|purchase\s+(more\s+)?credits?|no\s+credits?/i;
|
|
16
|
+
|
|
17
|
+
const RATE_LIMIT_RE = /\b429\b|rate[_\s]+limit/i;
|
|
18
|
+
|
|
19
|
+
const SERVER_ERROR_RE =
|
|
20
|
+
/\b502\b|\b503\b|\b504\b|timeout|timed\s+out|ETIMEDOUT|ECONNRESET|temporarily\s+unavailable|overload|service\s+unavailable|bad\s+gateway|gateway\s+timeout/i;
|
|
21
|
+
|
|
22
|
+
export function classifyUserError(errorText: string): UserErrorCategory {
|
|
23
|
+
if (AUTH_RE.test(errorText)) return "auth";
|
|
24
|
+
if (KEY_LIMIT_RE.test(errorText)) return "key-limit";
|
|
25
|
+
if (CREDITS_RE.test(errorText)) return "credits";
|
|
26
|
+
if (RATE_LIMIT_RE.test(errorText)) return "rate-limit";
|
|
27
|
+
if (SERVER_ERROR_RE.test(errorText)) return "server-error";
|
|
28
|
+
return "generic";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MESSAGES: Record<UserErrorCategory, { platform: string; byok: string }> =
|
|
32
|
+
{
|
|
33
|
+
"key-limit": {
|
|
34
|
+
platform: "I've reached my usage limit for now. Please try again later.",
|
|
35
|
+
byok: "Your API key has hit its spending limit. Check your provider's key settings to increase it.",
|
|
36
|
+
},
|
|
37
|
+
"rate-limit": {
|
|
38
|
+
platform:
|
|
39
|
+
"I'm handling too many requests right now — please try again in a moment.",
|
|
40
|
+
byok: "Your API key is being rate-limited. Try again in a moment.",
|
|
41
|
+
},
|
|
42
|
+
auth: {
|
|
43
|
+
platform:
|
|
44
|
+
"Something went wrong on my end. This has been logged and the admin will be notified.",
|
|
45
|
+
byok: "Your API key appears to be invalid or expired. Please update it.",
|
|
46
|
+
},
|
|
47
|
+
credits: {
|
|
48
|
+
platform: "I've reached my usage limit for now. Please try again later.",
|
|
49
|
+
byok: "Your API provider account has insufficient credits. Add credits to continue.",
|
|
50
|
+
},
|
|
51
|
+
"server-error": {
|
|
52
|
+
platform:
|
|
53
|
+
"The AI service is temporarily unavailable. Please try again in a few minutes.",
|
|
54
|
+
byok: "The AI service is temporarily unavailable. Please try again in a few minutes.",
|
|
55
|
+
},
|
|
56
|
+
generic: {
|
|
57
|
+
platform:
|
|
58
|
+
"Something went wrong processing your request. Please try again.",
|
|
59
|
+
byok: "Something went wrong processing your request. Please try again, or check your API key and provider status.",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function friendlyErrorMessage(
|
|
64
|
+
category: UserErrorCategory,
|
|
65
|
+
mode: "platform" | "byok",
|
|
66
|
+
consoleUrl?: string,
|
|
67
|
+
): string {
|
|
68
|
+
let message = MESSAGES[category][mode];
|
|
69
|
+
const base = consoleUrl?.replace(/\/+$/, "");
|
|
70
|
+
if (base && mode === "platform") {
|
|
71
|
+
if (category === "key-limit" || category === "credits") {
|
|
72
|
+
message += `\n\nUpgrade your plan: ${base}/dashboard/billing`;
|
|
73
|
+
} else if (category === "auth") {
|
|
74
|
+
return `Your Anthropic session has expired. Please reconnect: ${base}/dashboard/model`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return message;
|
|
78
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { Message } from "chat";
|
|
3
|
+
import type { DiscordNativeAdapter } from "../adapters/discord-native.js";
|
|
4
|
+
import { downloadMediaFromUrl, mimeToMediaType } from "../core/media.js";
|
|
5
|
+
import { logger } from "../logger.js";
|
|
6
|
+
import type {
|
|
7
|
+
EgressFile,
|
|
8
|
+
IngressMessage,
|
|
9
|
+
MessageAttachment,
|
|
10
|
+
NormalizeContext,
|
|
11
|
+
PlatformBridge,
|
|
12
|
+
} from "../types.js";
|
|
13
|
+
|
|
14
|
+
export class DiscordBridge implements PlatformBridge {
|
|
15
|
+
readonly platform = "discord";
|
|
16
|
+
|
|
17
|
+
constructor(private readonly adapter: DiscordNativeAdapter) {}
|
|
18
|
+
|
|
19
|
+
parseThread(threadId: string): { externalId: string; isDM: boolean } {
|
|
20
|
+
const parts = threadId.split(":");
|
|
21
|
+
const externalId = parts.slice(1).join(":");
|
|
22
|
+
const isDM = parts.length >= 2 && parts[1] === "@me";
|
|
23
|
+
return { externalId, isDM };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async normalize(
|
|
27
|
+
threadId: string,
|
|
28
|
+
message: unknown,
|
|
29
|
+
ctx: NormalizeContext,
|
|
30
|
+
spaceId: string,
|
|
31
|
+
): Promise<IngressMessage | null> {
|
|
32
|
+
const msg = message as Message;
|
|
33
|
+
if (msg.author.isMe) return null;
|
|
34
|
+
|
|
35
|
+
let text = msg.text.trim();
|
|
36
|
+
const rawAttachments = msg.attachments ?? [];
|
|
37
|
+
if (!text && rawAttachments.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
const botUserId = this.adapter.botUserId;
|
|
40
|
+
if (botUserId) {
|
|
41
|
+
text = text.replace(
|
|
42
|
+
new RegExp(`<@!?${botUserId}>`, "g"),
|
|
43
|
+
`@${ctx.botUserName}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const metadata = msg.metadata as {
|
|
48
|
+
isReplyToBot?: boolean;
|
|
49
|
+
replyToMessageId?: string;
|
|
50
|
+
platformMessageId?: string;
|
|
51
|
+
};
|
|
52
|
+
const isReplyToBot = metadata?.isReplyToBot ?? false;
|
|
53
|
+
|
|
54
|
+
const attachments: MessageAttachment[] = [];
|
|
55
|
+
if (ctx.media.enabled && rawAttachments.length > 0) {
|
|
56
|
+
if (await ctx.isOverQuota()) {
|
|
57
|
+
logger.warn("Skipping media download — storage quota exceeded", {
|
|
58
|
+
spaceId,
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
const workspace = ctx.getWorkspace(spaceId);
|
|
62
|
+
const inboxDir = path.join(workspace, "inbox");
|
|
63
|
+
for (const att of rawAttachments) {
|
|
64
|
+
if (!att.url) continue;
|
|
65
|
+
const type = mimeToMediaType(
|
|
66
|
+
att.mimeType || "application/octet-stream",
|
|
67
|
+
);
|
|
68
|
+
const result = await downloadMediaFromUrl(att.url, {
|
|
69
|
+
type,
|
|
70
|
+
mimeType: att.mimeType || "application/octet-stream",
|
|
71
|
+
filename: att.name,
|
|
72
|
+
expectedSizeBytes: att.size,
|
|
73
|
+
maxSizeBytes: ctx.media.maxSizeBytes,
|
|
74
|
+
outputDir: inboxDir,
|
|
75
|
+
});
|
|
76
|
+
if (result) attachments.push(result);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { externalId, isDM } = this.parseThread(threadId);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
platform: "discord",
|
|
85
|
+
spaceId,
|
|
86
|
+
conversationExternalId: externalId,
|
|
87
|
+
callerId: `discord:${msg.author.userId || "unknown"}`,
|
|
88
|
+
authorName: msg.author.userName,
|
|
89
|
+
text,
|
|
90
|
+
isDM,
|
|
91
|
+
isReplyToBot,
|
|
92
|
+
attachments,
|
|
93
|
+
replyToPlatformMessageId: metadata?.replyToMessageId ?? undefined,
|
|
94
|
+
platformMessageId: metadata?.platformMessageId ?? undefined,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async sendReply(
|
|
99
|
+
threadId: string,
|
|
100
|
+
text: string,
|
|
101
|
+
files?: EgressFile[],
|
|
102
|
+
): Promise<string | undefined> {
|
|
103
|
+
if (files && files.length > 0) {
|
|
104
|
+
return this.sendWithFiles(threadId, text, files);
|
|
105
|
+
} else if (text) {
|
|
106
|
+
const sent = await this.adapter.postMessage(threadId, text);
|
|
107
|
+
return sent.id;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async deleteMessages(threadId: string, messageIds: string[]): Promise<void> {
|
|
113
|
+
for (const id of messageIds) {
|
|
114
|
+
try {
|
|
115
|
+
await this.adapter.deleteMessage(threadId, id);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
logger.debug("Discord deleteMessage failed (best-effort)", {
|
|
118
|
+
messageId: id,
|
|
119
|
+
error: err instanceof Error ? err.message : String(err),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async sendWithFiles(
|
|
126
|
+
threadId: string,
|
|
127
|
+
text: string,
|
|
128
|
+
files: EgressFile[],
|
|
129
|
+
): Promise<string | undefined> {
|
|
130
|
+
const client = this.adapter.discordClient;
|
|
131
|
+
const { channelId, threadId: discordThreadId } =
|
|
132
|
+
this.adapter.decodeThreadId(threadId);
|
|
133
|
+
const targetId = discordThreadId || channelId;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const channel = await client.channels.fetch(targetId);
|
|
137
|
+
if (!channel || !("send" in channel)) {
|
|
138
|
+
logger.warn("Discord channel not sendable, falling back to text-only", {
|
|
139
|
+
targetId,
|
|
140
|
+
});
|
|
141
|
+
if (text) {
|
|
142
|
+
const sent = await this.adapter.postMessage(threadId, text);
|
|
143
|
+
return sent.id;
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const discordFiles = files.map((f) => ({
|
|
149
|
+
attachment: f.path,
|
|
150
|
+
name: f.filename,
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
const sent = await (
|
|
154
|
+
channel as { send: (opts: unknown) => Promise<{ id: string }> }
|
|
155
|
+
).send({
|
|
156
|
+
content: text || undefined,
|
|
157
|
+
files: discordFiles,
|
|
158
|
+
});
|
|
159
|
+
return sent?.id;
|
|
160
|
+
} catch (err) {
|
|
161
|
+
logger.error("Failed to send files via Discord", {
|
|
162
|
+
error: err instanceof Error ? err.message : String(err),
|
|
163
|
+
});
|
|
164
|
+
if (text) {
|
|
165
|
+
const sent = await this.adapter.postMessage(threadId, text);
|
|
166
|
+
return sent.id;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { Adapter, Message } from "chat";
|
|
4
|
+
import { downloadMediaFromUrl, mimeToMediaType } from "../core/media.js";
|
|
5
|
+
import { logger } from "../logger.js";
|
|
6
|
+
import type {
|
|
7
|
+
EgressFile,
|
|
8
|
+
IngressMessage,
|
|
9
|
+
MessageAttachment,
|
|
10
|
+
NormalizeContext,
|
|
11
|
+
PlatformBridge,
|
|
12
|
+
} from "../types.js";
|
|
13
|
+
|
|
14
|
+
export class SlackBridge implements PlatformBridge {
|
|
15
|
+
readonly platform = "slack";
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly adapter: Adapter,
|
|
19
|
+
private readonly botToken: string,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
parseThread(threadId: string): { externalId: string; isDM: boolean } {
|
|
23
|
+
const parts = threadId.split(":");
|
|
24
|
+
const externalId = parts.slice(1).join(":");
|
|
25
|
+
const ch = parts[1] || "";
|
|
26
|
+
const isDM = ch.startsWith("D") || ch.startsWith("G");
|
|
27
|
+
return { externalId, isDM };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async normalize(
|
|
31
|
+
threadId: string,
|
|
32
|
+
message: unknown,
|
|
33
|
+
ctx: NormalizeContext,
|
|
34
|
+
spaceId: string,
|
|
35
|
+
): Promise<IngressMessage | null> {
|
|
36
|
+
const msg = message as Message;
|
|
37
|
+
if (msg.author.isMe) return null;
|
|
38
|
+
|
|
39
|
+
const text = msg.text.trim();
|
|
40
|
+
const rawAttachments = msg.attachments ?? [];
|
|
41
|
+
if (!text && rawAttachments.length === 0) return null;
|
|
42
|
+
|
|
43
|
+
const attachments: MessageAttachment[] = [];
|
|
44
|
+
if (ctx.media.enabled && rawAttachments.length > 0) {
|
|
45
|
+
if (await ctx.isOverQuota()) {
|
|
46
|
+
logger.warn("Skipping media download — storage quota exceeded", {
|
|
47
|
+
spaceId,
|
|
48
|
+
});
|
|
49
|
+
} else {
|
|
50
|
+
const workspace = ctx.getWorkspace(spaceId);
|
|
51
|
+
const inboxDir = path.join(workspace, "inbox");
|
|
52
|
+
for (const att of rawAttachments) {
|
|
53
|
+
const url = att.url || (att as { url_private?: string }).url_private;
|
|
54
|
+
if (!url) continue;
|
|
55
|
+
const type = mimeToMediaType(
|
|
56
|
+
att.mimeType || "application/octet-stream",
|
|
57
|
+
);
|
|
58
|
+
const result = await downloadMediaFromUrl(url, {
|
|
59
|
+
type,
|
|
60
|
+
mimeType: att.mimeType || "application/octet-stream",
|
|
61
|
+
filename: att.name,
|
|
62
|
+
expectedSizeBytes: att.size,
|
|
63
|
+
maxSizeBytes: ctx.media.maxSizeBytes,
|
|
64
|
+
outputDir: inboxDir,
|
|
65
|
+
headers: { Authorization: `Bearer ${this.botToken}` },
|
|
66
|
+
});
|
|
67
|
+
if (result) attachments.push(result);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { externalId, isDM } = this.parseThread(threadId);
|
|
73
|
+
|
|
74
|
+
// Extract Slack-specific fields from raw event for reply chain tracking
|
|
75
|
+
const raw = msg.raw as
|
|
76
|
+
| {
|
|
77
|
+
ts?: string;
|
|
78
|
+
thread_ts?: string;
|
|
79
|
+
}
|
|
80
|
+
| undefined;
|
|
81
|
+
const slackTs = raw?.ts;
|
|
82
|
+
const slackThreadTs = raw?.thread_ts;
|
|
83
|
+
// In Slack, a threaded reply has thread_ts pointing to the parent message.
|
|
84
|
+
// isReplyToBot: we can't determine this without knowing the parent author;
|
|
85
|
+
// will be resolved via platform ID lookup in the runtime.
|
|
86
|
+
const isReplyToBot =
|
|
87
|
+
(msg.metadata as { isReplyToBot?: boolean })?.isReplyToBot ?? false;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
platform: "slack",
|
|
91
|
+
spaceId,
|
|
92
|
+
conversationExternalId: externalId,
|
|
93
|
+
callerId: `slack:${msg.author.userId || "unknown"}`,
|
|
94
|
+
authorName: msg.author.userName,
|
|
95
|
+
text,
|
|
96
|
+
isDM,
|
|
97
|
+
isReplyToBot,
|
|
98
|
+
attachments,
|
|
99
|
+
replyToPlatformMessageId:
|
|
100
|
+
slackThreadTs && slackThreadTs !== slackTs ? slackThreadTs : undefined,
|
|
101
|
+
platformMessageId: slackTs,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async sendReply(
|
|
106
|
+
threadId: string,
|
|
107
|
+
text: string,
|
|
108
|
+
files?: EgressFile[],
|
|
109
|
+
): Promise<string | undefined> {
|
|
110
|
+
let sentPlatformId: string | undefined;
|
|
111
|
+
if (text) {
|
|
112
|
+
const sent = await this.adapter.postMessage(threadId, text);
|
|
113
|
+
sentPlatformId = sent.id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (files && files.length > 0) {
|
|
117
|
+
await this.uploadFiles(threadId, files);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return sentPlatformId;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async deleteMessages(
|
|
124
|
+
_threadId: string,
|
|
125
|
+
_messageIds: string[],
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
// Slack delete not implemented — silent no-op for v1
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async uploadFiles(
|
|
131
|
+
threadId: string,
|
|
132
|
+
files: EgressFile[],
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const parts = threadId.split(":");
|
|
135
|
+
const channelId = parts.length >= 2 ? parts[1] : threadId;
|
|
136
|
+
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
try {
|
|
139
|
+
const buffer = fs.readFileSync(file.path);
|
|
140
|
+
const form = new FormData();
|
|
141
|
+
form.append("channel_id", channelId);
|
|
142
|
+
form.append("filename", file.filename);
|
|
143
|
+
form.append(
|
|
144
|
+
"file",
|
|
145
|
+
new Blob([buffer], { type: file.mimeType }),
|
|
146
|
+
file.filename,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const resp = await fetch("https://slack.com/api/files.uploadV2", {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { Authorization: `Bearer ${this.botToken}` },
|
|
152
|
+
body: form,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!resp.ok) {
|
|
156
|
+
logger.error("Slack file upload HTTP error", {
|
|
157
|
+
filename: file.filename,
|
|
158
|
+
status: resp.status,
|
|
159
|
+
});
|
|
160
|
+
} else {
|
|
161
|
+
const body = (await resp.json()) as { ok?: boolean; error?: string };
|
|
162
|
+
if (!body.ok) {
|
|
163
|
+
logger.error("Slack file upload API error", {
|
|
164
|
+
filename: file.filename,
|
|
165
|
+
error: body.error,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (err) {
|
|
170
|
+
logger.error("Slack file upload failed", {
|
|
171
|
+
filename: file.filename,
|
|
172
|
+
error: err instanceof Error ? err.message : String(err),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { Adapter, Message } from "chat";
|
|
4
|
+
import { downloadMediaFromUrl, mimeToMediaType } from "../core/media.js";
|
|
5
|
+
import { logger } from "../logger.js";
|
|
6
|
+
import type {
|
|
7
|
+
EgressFile,
|
|
8
|
+
IngressMessage,
|
|
9
|
+
MessageAttachment,
|
|
10
|
+
NormalizeContext,
|
|
11
|
+
PlatformBridge,
|
|
12
|
+
} from "../types.js";
|
|
13
|
+
|
|
14
|
+
export class TeamsBridge implements PlatformBridge {
|
|
15
|
+
readonly platform = "teams";
|
|
16
|
+
|
|
17
|
+
constructor(private readonly adapter: Adapter) {}
|
|
18
|
+
|
|
19
|
+
parseThread(threadId: string): { externalId: string; isDM: boolean } {
|
|
20
|
+
// Teams thread IDs are "teams:<base64url-conversationId>:<base64url-serviceUrl>"
|
|
21
|
+
const parts = threadId.split(":");
|
|
22
|
+
const externalId = parts.slice(1).join(":");
|
|
23
|
+
|
|
24
|
+
// Teams DMs have conversation IDs that don't start with "19:"
|
|
25
|
+
// The conversationId is base64url-encoded in parts[1]
|
|
26
|
+
let isDM = true;
|
|
27
|
+
try {
|
|
28
|
+
const conversationId = Buffer.from(parts[1] || "", "base64url").toString(
|
|
29
|
+
"utf-8",
|
|
30
|
+
);
|
|
31
|
+
isDM = !conversationId.startsWith("19:");
|
|
32
|
+
} catch {
|
|
33
|
+
// If decoding fails, assume DM
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { externalId, isDM };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async normalize(
|
|
40
|
+
threadId: string,
|
|
41
|
+
message: unknown,
|
|
42
|
+
ctx: NormalizeContext,
|
|
43
|
+
spaceId: string,
|
|
44
|
+
): Promise<IngressMessage | null> {
|
|
45
|
+
const msg = message as Message;
|
|
46
|
+
if (msg.author.isMe) return null;
|
|
47
|
+
|
|
48
|
+
const text = msg.text.trim();
|
|
49
|
+
const rawAttachments = msg.attachments ?? [];
|
|
50
|
+
if (!text && rawAttachments.length === 0) return null;
|
|
51
|
+
|
|
52
|
+
// Download media attachments
|
|
53
|
+
const attachments: MessageAttachment[] = [];
|
|
54
|
+
if (ctx.media.enabled && rawAttachments.length > 0) {
|
|
55
|
+
const workspace = ctx.getWorkspace(spaceId);
|
|
56
|
+
const inboxDir = path.join(workspace, "inbox");
|
|
57
|
+
for (const att of rawAttachments) {
|
|
58
|
+
if (!att.url) continue;
|
|
59
|
+
const type = mimeToMediaType(
|
|
60
|
+
att.mimeType || "application/octet-stream",
|
|
61
|
+
);
|
|
62
|
+
const result = await downloadMediaFromUrl(att.url, {
|
|
63
|
+
type,
|
|
64
|
+
mimeType: att.mimeType || "application/octet-stream",
|
|
65
|
+
filename: att.name,
|
|
66
|
+
expectedSizeBytes: att.size,
|
|
67
|
+
maxSizeBytes: ctx.media.maxSizeBytes,
|
|
68
|
+
outputDir: inboxDir,
|
|
69
|
+
});
|
|
70
|
+
if (result) attachments.push(result);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { externalId, isDM } = this.parseThread(threadId);
|
|
75
|
+
|
|
76
|
+
// Check reply-to-bot via raw activity
|
|
77
|
+
const raw = msg.raw as { replyToId?: string; id?: string } | undefined;
|
|
78
|
+
const isReplyToBot = Boolean(raw?.replyToId);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
platform: "teams",
|
|
82
|
+
spaceId,
|
|
83
|
+
conversationExternalId: externalId,
|
|
84
|
+
callerId: `teams:${msg.author.userId || "unknown"}`,
|
|
85
|
+
authorName: msg.author.userName,
|
|
86
|
+
text,
|
|
87
|
+
isDM,
|
|
88
|
+
isReplyToBot,
|
|
89
|
+
attachments,
|
|
90
|
+
replyToPlatformMessageId: raw?.replyToId ?? undefined,
|
|
91
|
+
platformMessageId: raw?.id ?? msg.id,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async sendReply(
|
|
96
|
+
threadId: string,
|
|
97
|
+
text: string,
|
|
98
|
+
files?: EgressFile[],
|
|
99
|
+
): Promise<string | undefined> {
|
|
100
|
+
if (files && files.length > 0) {
|
|
101
|
+
return this.sendWithFiles(threadId, text, files);
|
|
102
|
+
} else if (text) {
|
|
103
|
+
const sent = await this.adapter.postMessage(threadId, text);
|
|
104
|
+
return sent.id;
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async deleteMessages(
|
|
110
|
+
_threadId: string,
|
|
111
|
+
_messageIds: string[],
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
// Teams delete not implemented — silent no-op for v1
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private async sendWithFiles(
|
|
117
|
+
threadId: string,
|
|
118
|
+
text: string,
|
|
119
|
+
files: EgressFile[],
|
|
120
|
+
): Promise<string | undefined> {
|
|
121
|
+
// Build file uploads from EgressFile paths
|
|
122
|
+
const fileUploads: { filename: string; mimeType: string; data: Buffer }[] =
|
|
123
|
+
[];
|
|
124
|
+
for (const file of files) {
|
|
125
|
+
try {
|
|
126
|
+
const buffer = fs.readFileSync(file.path);
|
|
127
|
+
fileUploads.push({
|
|
128
|
+
filename: file.filename,
|
|
129
|
+
mimeType: file.mimeType,
|
|
130
|
+
data: buffer,
|
|
131
|
+
});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger.error("Failed to read egress file", {
|
|
134
|
+
path: file.path,
|
|
135
|
+
error: err instanceof Error ? err.message : String(err),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Send text + files together via postMessage
|
|
141
|
+
// Teams adapter extracts files from the message object and converts to inline attachments
|
|
142
|
+
try {
|
|
143
|
+
const sent = await this.adapter.postMessage(threadId, {
|
|
144
|
+
markdown: text || "",
|
|
145
|
+
files: fileUploads,
|
|
146
|
+
} as never);
|
|
147
|
+
return sent.id;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
logger.error("Teams send with files failed", {
|
|
150
|
+
error: err instanceof Error ? err.message : String(err),
|
|
151
|
+
});
|
|
152
|
+
// Fall back to text-only
|
|
153
|
+
if (text) {
|
|
154
|
+
const sent = await this.adapter.postMessage(threadId, text);
|
|
155
|
+
return sent.id;
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|