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,75 @@
|
|
|
1
|
+
# Copy to mercury.yaml in your project root (optional).
|
|
2
|
+
# Precedence: MERCURY_* env vars override this file when the env key is set.
|
|
3
|
+
# Secrets (API keys, tokens, MERCURY_API_SECRET, etc.) belong in .env only — not here.
|
|
4
|
+
|
|
5
|
+
# server:
|
|
6
|
+
# port: 8787
|
|
7
|
+
# bot_username: mercury
|
|
8
|
+
|
|
9
|
+
# model:
|
|
10
|
+
# chain:
|
|
11
|
+
# - provider: anthropic
|
|
12
|
+
# model: claude-sonnet-4-20250514
|
|
13
|
+
# # Legacy single primary + optional fallback (when chain is omitted):
|
|
14
|
+
# # provider: anthropic
|
|
15
|
+
# # model: claude-sonnet-4-20250514
|
|
16
|
+
# # fallback_provider: openai
|
|
17
|
+
# # fallback: gpt-4o-mini
|
|
18
|
+
# # max_retries_per_leg: 2
|
|
19
|
+
# # chain_budget_ms: 120000
|
|
20
|
+
# # capabilities: { tools: false }
|
|
21
|
+
|
|
22
|
+
# Top-level alias for model.chain (optional; model.chain wins if both are set):
|
|
23
|
+
# model_chain:
|
|
24
|
+
# - provider: google
|
|
25
|
+
# model: gemini-2.5-flash
|
|
26
|
+
|
|
27
|
+
# ingress:
|
|
28
|
+
# discord: false
|
|
29
|
+
# slack: false
|
|
30
|
+
# teams: false
|
|
31
|
+
# whatsapp: false
|
|
32
|
+
# telegram: false
|
|
33
|
+
|
|
34
|
+
# runtime:
|
|
35
|
+
# data_dir: .mercury
|
|
36
|
+
# # auth_path: ...
|
|
37
|
+
# # whatsapp_auth_dir: .mercury/whatsapp-auth
|
|
38
|
+
# max_concurrency: 2
|
|
39
|
+
# log_level: info
|
|
40
|
+
# log_format: text
|
|
41
|
+
# rate_limit_per_user: 10
|
|
42
|
+
# rate_limit_window_ms: 60000
|
|
43
|
+
|
|
44
|
+
# trigger:
|
|
45
|
+
# patterns: "@Mercury,Mercury"
|
|
46
|
+
# match: mention
|
|
47
|
+
|
|
48
|
+
# conditional_context:
|
|
49
|
+
# enabled: true
|
|
50
|
+
# classifier: heuristic
|
|
51
|
+
# # classifier_provider: groq
|
|
52
|
+
# # classifier_model: llama-3.3-70b-versatile
|
|
53
|
+
|
|
54
|
+
# compaction:
|
|
55
|
+
# keep_recent_tokens: 20000
|
|
56
|
+
# # After each full-session run, auto-compact if the pi session has more than this many entries (10–10000). Omit to disable.
|
|
57
|
+
# # auto_compact_threshold: 50
|
|
58
|
+
|
|
59
|
+
# agent:
|
|
60
|
+
# image: ghcr.io/michaelliv/mercury-agent:latest
|
|
61
|
+
# container_timeout_ms: 300000
|
|
62
|
+
# container_bwrap_docker_compat: false
|
|
63
|
+
|
|
64
|
+
# discord:
|
|
65
|
+
# gateway_duration_ms: 600000
|
|
66
|
+
|
|
67
|
+
# telegram:
|
|
68
|
+
# format_enabled: true
|
|
69
|
+
|
|
70
|
+
# media:
|
|
71
|
+
# enabled: true
|
|
72
|
+
# max_size_mb: 10
|
|
73
|
+
|
|
74
|
+
# permissions:
|
|
75
|
+
# admins: ""
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Discord adapter using discord.js for persistent WebSocket connection.
|
|
3
|
+
*
|
|
4
|
+
* Similar to the WhatsApp/Baileys adapter pattern — maintains a persistent
|
|
5
|
+
* connection instead of the serverless gateway approach.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type Adapter,
|
|
10
|
+
type AdapterPostableMessage,
|
|
11
|
+
type ChatInstance,
|
|
12
|
+
type EmojiValue,
|
|
13
|
+
type FetchOptions,
|
|
14
|
+
type FetchResult,
|
|
15
|
+
type FormattedContent,
|
|
16
|
+
Message,
|
|
17
|
+
parseMarkdown,
|
|
18
|
+
type RawMessage,
|
|
19
|
+
stringifyMarkdown,
|
|
20
|
+
type ThreadInfo,
|
|
21
|
+
type WebhookOptions,
|
|
22
|
+
} from "chat";
|
|
23
|
+
import {
|
|
24
|
+
Client,
|
|
25
|
+
type Message as DiscordMessage,
|
|
26
|
+
Events,
|
|
27
|
+
GatewayIntentBits,
|
|
28
|
+
type OmitPartialGroupDMChannel,
|
|
29
|
+
Partials,
|
|
30
|
+
} from "discord.js";
|
|
31
|
+
import { logger } from "../logger.js";
|
|
32
|
+
|
|
33
|
+
/** Discord's maximum message length */
|
|
34
|
+
export const DISCORD_MAX_LENGTH = 2000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Split a message into chunks that fit within Discord's character limit.
|
|
38
|
+
* Tries to break at natural boundaries (paragraphs, lines, spaces) when possible.
|
|
39
|
+
*/
|
|
40
|
+
export function chunkMessage(text: string, maxLength: number): string[] {
|
|
41
|
+
if (text.length <= maxLength) return [text];
|
|
42
|
+
|
|
43
|
+
const chunks: string[] = [];
|
|
44
|
+
let remaining = text;
|
|
45
|
+
|
|
46
|
+
while (remaining.length > maxLength) {
|
|
47
|
+
// Try to break at paragraph boundary
|
|
48
|
+
let breakPoint = remaining.lastIndexOf("\n\n", maxLength);
|
|
49
|
+
|
|
50
|
+
// Fall back to line break
|
|
51
|
+
if (breakPoint < maxLength / 2) {
|
|
52
|
+
breakPoint = remaining.lastIndexOf("\n", maxLength);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fall back to space
|
|
56
|
+
if (breakPoint < maxLength / 2) {
|
|
57
|
+
breakPoint = remaining.lastIndexOf(" ", maxLength);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Hard break if no good boundary found
|
|
61
|
+
if (breakPoint <= 0) {
|
|
62
|
+
breakPoint = maxLength;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
chunks.push(remaining.slice(0, breakPoint).trim());
|
|
66
|
+
remaining = remaining.slice(breakPoint).trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (remaining) {
|
|
70
|
+
chunks.push(remaining);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return chunks;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type DiscordThreadId = {
|
|
77
|
+
guildId: string;
|
|
78
|
+
channelId: string;
|
|
79
|
+
threadId?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export interface DiscordNativeAdapterOptions {
|
|
83
|
+
/** Bot token */
|
|
84
|
+
botToken: string;
|
|
85
|
+
/** Bot username for trigger matching */
|
|
86
|
+
userName?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class DiscordNativeAdapter
|
|
90
|
+
implements Adapter<DiscordThreadId, DiscordMessage>
|
|
91
|
+
{
|
|
92
|
+
readonly name = "discord";
|
|
93
|
+
readonly userName: string;
|
|
94
|
+
|
|
95
|
+
private chat?: ChatInstance;
|
|
96
|
+
private client: Client;
|
|
97
|
+
private readonly botToken: string;
|
|
98
|
+
|
|
99
|
+
constructor(options: DiscordNativeAdapterOptions) {
|
|
100
|
+
this.userName = options.userName ?? "mercury";
|
|
101
|
+
this.botToken = options.botToken;
|
|
102
|
+
|
|
103
|
+
this.client = new Client({
|
|
104
|
+
intents: [
|
|
105
|
+
GatewayIntentBits.Guilds,
|
|
106
|
+
GatewayIntentBits.GuildMessages,
|
|
107
|
+
GatewayIntentBits.MessageContent,
|
|
108
|
+
GatewayIntentBits.DirectMessages,
|
|
109
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
110
|
+
],
|
|
111
|
+
partials: [Partials.Channel, Partials.Message],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get botUserId(): string | undefined {
|
|
116
|
+
return this.client.user?.id;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get discordClient(): Client {
|
|
120
|
+
return this.client;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async initialize(chat: ChatInstance): Promise<void> {
|
|
124
|
+
this.chat = chat;
|
|
125
|
+
logger.info("Discord native adapter initializing");
|
|
126
|
+
|
|
127
|
+
this.client.on(Events.ClientReady, () => {
|
|
128
|
+
logger.info("Discord native adapter connected", {
|
|
129
|
+
username: this.client.user?.username,
|
|
130
|
+
id: this.client.user?.id,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
this.client.on(Events.MessageCreate, (message) => {
|
|
135
|
+
void this.handleIncomingMessage(message);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
this.client.on(Events.Error, (error) => {
|
|
139
|
+
logger.error("Discord client error", { error: error.message });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await this.client.login(this.botToken);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async handleWebhook(
|
|
146
|
+
_request: Request,
|
|
147
|
+
_options?: WebhookOptions,
|
|
148
|
+
): Promise<Response> {
|
|
149
|
+
// No webhook needed — we use persistent WebSocket
|
|
150
|
+
return new Response("Discord native adapter uses WebSocket, no webhook.", {
|
|
151
|
+
status: 202,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
channelIdFromThreadId(threadId: string): string {
|
|
156
|
+
const parts = threadId.split(":");
|
|
157
|
+
return `discord:${parts[1]}:${parts[2]}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
encodeThreadId(platformData: DiscordThreadId): string {
|
|
161
|
+
const parts = ["discord", platformData.guildId, platformData.channelId];
|
|
162
|
+
if (platformData.threadId) {
|
|
163
|
+
parts.push(platformData.threadId);
|
|
164
|
+
}
|
|
165
|
+
return parts.join(":");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
decodeThreadId(threadId: string): DiscordThreadId {
|
|
169
|
+
const parts = threadId.split(":");
|
|
170
|
+
if (parts.length < 3 || parts[0] !== "discord") {
|
|
171
|
+
throw new Error(`Invalid Discord thread ID: ${threadId}`);
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
guildId: parts[1],
|
|
175
|
+
channelId: parts[2],
|
|
176
|
+
threadId: parts[3],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async postMessage(
|
|
181
|
+
threadId: string,
|
|
182
|
+
message: AdapterPostableMessage,
|
|
183
|
+
): Promise<RawMessage<DiscordMessage>> {
|
|
184
|
+
const { channelId, threadId: discordThreadId } =
|
|
185
|
+
this.decodeThreadId(threadId);
|
|
186
|
+
|
|
187
|
+
const targetId = discordThreadId || channelId;
|
|
188
|
+
const channel = await this.client.channels.fetch(targetId);
|
|
189
|
+
|
|
190
|
+
if (!channel || !("send" in channel)) {
|
|
191
|
+
throw new Error(`Cannot send to channel: ${targetId}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const text = this.postableToText(message);
|
|
195
|
+
const chunks = chunkMessage(text, DISCORD_MAX_LENGTH);
|
|
196
|
+
|
|
197
|
+
let lastSent: DiscordMessage | undefined;
|
|
198
|
+
for (const chunk of chunks) {
|
|
199
|
+
lastSent = await channel.send(chunk);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// lastSent is guaranteed to be defined since chunks always has at least one element
|
|
203
|
+
const sent = lastSent as DiscordMessage;
|
|
204
|
+
return {
|
|
205
|
+
id: sent.id,
|
|
206
|
+
threadId,
|
|
207
|
+
raw: sent,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async editMessage(
|
|
212
|
+
threadId: string,
|
|
213
|
+
messageId: string,
|
|
214
|
+
message: AdapterPostableMessage,
|
|
215
|
+
): Promise<RawMessage<DiscordMessage>> {
|
|
216
|
+
const { channelId, threadId: discordThreadId } =
|
|
217
|
+
this.decodeThreadId(threadId);
|
|
218
|
+
|
|
219
|
+
const targetId = discordThreadId || channelId;
|
|
220
|
+
const channel = await this.client.channels.fetch(targetId);
|
|
221
|
+
|
|
222
|
+
if (!channel || !("messages" in channel)) {
|
|
223
|
+
throw new Error(`Cannot edit in channel: ${targetId}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const msg = await channel.messages.fetch(messageId);
|
|
227
|
+
const text = this.postableToText(message);
|
|
228
|
+
const edited = await msg.edit(text);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
id: edited.id,
|
|
232
|
+
threadId,
|
|
233
|
+
raw: edited,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async deleteMessage(threadId: string, messageId: string): Promise<void> {
|
|
238
|
+
const { channelId, threadId: discordThreadId } =
|
|
239
|
+
this.decodeThreadId(threadId);
|
|
240
|
+
|
|
241
|
+
const targetId = discordThreadId || channelId;
|
|
242
|
+
const channel = await this.client.channels.fetch(targetId);
|
|
243
|
+
|
|
244
|
+
if (!channel || !("messages" in channel)) {
|
|
245
|
+
throw new Error(`Cannot delete in channel: ${targetId}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const msg = await channel.messages.fetch(messageId);
|
|
249
|
+
await msg.delete();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async addReaction(
|
|
253
|
+
threadId: string,
|
|
254
|
+
messageId: string,
|
|
255
|
+
emoji: EmojiValue | string,
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
const { channelId, threadId: discordThreadId } =
|
|
258
|
+
this.decodeThreadId(threadId);
|
|
259
|
+
|
|
260
|
+
const targetId = discordThreadId || channelId;
|
|
261
|
+
const channel = await this.client.channels.fetch(targetId);
|
|
262
|
+
|
|
263
|
+
if (!channel || !("messages" in channel)) {
|
|
264
|
+
throw new Error(`Cannot react in channel: ${targetId}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const msg = await channel.messages.fetch(messageId);
|
|
268
|
+
const emojiStr = typeof emoji === "string" ? emoji : emoji.toString();
|
|
269
|
+
await msg.react(emojiStr);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async removeReaction(
|
|
273
|
+
threadId: string,
|
|
274
|
+
messageId: string,
|
|
275
|
+
emoji: EmojiValue | string,
|
|
276
|
+
): Promise<void> {
|
|
277
|
+
const { channelId, threadId: discordThreadId } =
|
|
278
|
+
this.decodeThreadId(threadId);
|
|
279
|
+
|
|
280
|
+
const targetId = discordThreadId || channelId;
|
|
281
|
+
const channel = await this.client.channels.fetch(targetId);
|
|
282
|
+
|
|
283
|
+
if (!channel || !("messages" in channel)) {
|
|
284
|
+
throw new Error(`Cannot remove reaction in channel: ${targetId}`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const msg = await channel.messages.fetch(messageId);
|
|
288
|
+
const emojiStr = typeof emoji === "string" ? emoji : emoji.toString();
|
|
289
|
+
const reaction = msg.reactions.cache.find(
|
|
290
|
+
(r) => r.emoji.name === emojiStr || r.emoji.toString() === emojiStr,
|
|
291
|
+
);
|
|
292
|
+
if (reaction) {
|
|
293
|
+
await reaction.users.remove(this.client.user?.id);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async fetchMessages(
|
|
298
|
+
threadId: string,
|
|
299
|
+
options?: FetchOptions,
|
|
300
|
+
): Promise<FetchResult<DiscordMessage>> {
|
|
301
|
+
const { channelId, threadId: discordThreadId } =
|
|
302
|
+
this.decodeThreadId(threadId);
|
|
303
|
+
|
|
304
|
+
const targetId = discordThreadId || channelId;
|
|
305
|
+
const channel = await this.client.channels.fetch(targetId);
|
|
306
|
+
|
|
307
|
+
if (!channel || !("messages" in channel)) {
|
|
308
|
+
return { messages: [] };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const fetchOptions: { limit?: number; before?: string } = {};
|
|
312
|
+
if (options?.limit) fetchOptions.limit = options.limit;
|
|
313
|
+
if (options?.cursor) fetchOptions.before = options.cursor;
|
|
314
|
+
|
|
315
|
+
const messages = await channel.messages.fetch(fetchOptions);
|
|
316
|
+
const parsed = messages.map((msg) => this.parseMessage(msg));
|
|
317
|
+
|
|
318
|
+
return { messages: Array.from(parsed.values()) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async fetchThread(threadId: string): Promise<ThreadInfo> {
|
|
322
|
+
const {
|
|
323
|
+
guildId,
|
|
324
|
+
channelId,
|
|
325
|
+
threadId: discordThreadId,
|
|
326
|
+
} = this.decodeThreadId(threadId);
|
|
327
|
+
|
|
328
|
+
const isDM = guildId === "@me";
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
id: threadId,
|
|
332
|
+
channelId: `discord:${guildId}:${channelId}`,
|
|
333
|
+
isDM,
|
|
334
|
+
metadata: { guildId, channelId, threadId: discordThreadId },
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
parseMessage(raw: DiscordMessage): Message<DiscordMessage> {
|
|
339
|
+
const guildId = raw.guildId || "@me";
|
|
340
|
+
const channelId = raw.channelId;
|
|
341
|
+
const threadId = raw.thread?.id;
|
|
342
|
+
|
|
343
|
+
const fullThreadId = this.encodeThreadId({ guildId, channelId, threadId });
|
|
344
|
+
|
|
345
|
+
// Check if bot is mentioned
|
|
346
|
+
const isMention =
|
|
347
|
+
raw.mentions.users.has(this.client.user?.id || "") ||
|
|
348
|
+
raw.mentions.everyone ||
|
|
349
|
+
raw.content.includes(`<@${this.client.user?.id}>`);
|
|
350
|
+
|
|
351
|
+
return new Message({
|
|
352
|
+
id: raw.id,
|
|
353
|
+
threadId: fullThreadId,
|
|
354
|
+
text: raw.content,
|
|
355
|
+
formatted: parseMarkdown(raw.content),
|
|
356
|
+
raw,
|
|
357
|
+
isMention,
|
|
358
|
+
author: {
|
|
359
|
+
userId: raw.author.id,
|
|
360
|
+
userName: raw.author.username,
|
|
361
|
+
fullName: raw.author.displayName || raw.author.username,
|
|
362
|
+
isBot: raw.author.bot,
|
|
363
|
+
isMe: raw.author.id === this.client.user?.id,
|
|
364
|
+
},
|
|
365
|
+
metadata: {
|
|
366
|
+
dateSent: raw.createdAt,
|
|
367
|
+
edited: raw.editedAt !== null,
|
|
368
|
+
},
|
|
369
|
+
attachments: raw.attachments.map((a) => ({
|
|
370
|
+
type: this.getAttachmentType(a.contentType),
|
|
371
|
+
url: a.url,
|
|
372
|
+
name: a.name,
|
|
373
|
+
size: a.size,
|
|
374
|
+
mimeType: a.contentType || undefined,
|
|
375
|
+
})),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
renderFormatted(content: FormattedContent): string {
|
|
380
|
+
return stringifyMarkdown(content);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async startTyping(threadId: string): Promise<void> {
|
|
384
|
+
const { channelId, threadId: discordThreadId } =
|
|
385
|
+
this.decodeThreadId(threadId);
|
|
386
|
+
|
|
387
|
+
const targetId = discordThreadId || channelId;
|
|
388
|
+
const channel = await this.client.channels.fetch(targetId);
|
|
389
|
+
|
|
390
|
+
if (channel && "sendTyping" in channel) {
|
|
391
|
+
await channel.sendTyping();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async shutdown(): Promise<void> {
|
|
396
|
+
await this.client.destroy();
|
|
397
|
+
logger.info("Discord native adapter disconnected");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Handle incoming Discord message.
|
|
402
|
+
*/
|
|
403
|
+
private async handleIncomingMessage(
|
|
404
|
+
msg: OmitPartialGroupDMChannel<DiscordMessage>,
|
|
405
|
+
): Promise<void> {
|
|
406
|
+
// Ignore bot messages
|
|
407
|
+
if (msg.author.bot) return;
|
|
408
|
+
if (msg.author.id === this.client.user?.id) return;
|
|
409
|
+
|
|
410
|
+
// Ignore empty messages
|
|
411
|
+
if (!msg.content && msg.attachments.size === 0) return;
|
|
412
|
+
|
|
413
|
+
const guildId = msg.guildId || "@me";
|
|
414
|
+
const channelId = msg.channelId;
|
|
415
|
+
const threadId = msg.thread?.id;
|
|
416
|
+
|
|
417
|
+
const fullThreadId = this.encodeThreadId({ guildId, channelId, threadId });
|
|
418
|
+
|
|
419
|
+
// Convert mentions for trigger matching
|
|
420
|
+
let text = msg.content;
|
|
421
|
+
if (this.client.user?.id) {
|
|
422
|
+
text = text.replace(
|
|
423
|
+
new RegExp(`<@!?${this.client.user.id}>`, "g"),
|
|
424
|
+
`@${this.userName}`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const isDM = guildId === "@me";
|
|
429
|
+
const isMention =
|
|
430
|
+
msg.mentions.users.has(this.client.user?.id || "") ||
|
|
431
|
+
msg.content.includes(`<@${this.client.user?.id}>`);
|
|
432
|
+
|
|
433
|
+
// Check if this is a reply to one of our messages
|
|
434
|
+
let isReplyToBot = false;
|
|
435
|
+
if (msg.reference?.messageId && this.client.user?.id) {
|
|
436
|
+
try {
|
|
437
|
+
const channel = msg.channel;
|
|
438
|
+
if ("messages" in channel) {
|
|
439
|
+
const repliedTo = await channel.messages.fetch(
|
|
440
|
+
msg.reference.messageId,
|
|
441
|
+
);
|
|
442
|
+
isReplyToBot = repliedTo.author.id === this.client.user.id;
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
// Referenced message may be deleted or inaccessible
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
logger.debug("Discord native inbound", {
|
|
450
|
+
guildId,
|
|
451
|
+
channelId,
|
|
452
|
+
threadId,
|
|
453
|
+
isDM,
|
|
454
|
+
isMention,
|
|
455
|
+
isReplyToBot,
|
|
456
|
+
preview: text.slice(0, 80),
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
const incoming = new Message<DiscordMessage>({
|
|
460
|
+
id: msg.id,
|
|
461
|
+
threadId: fullThreadId,
|
|
462
|
+
text,
|
|
463
|
+
formatted: parseMarkdown(text),
|
|
464
|
+
raw: msg as DiscordMessage,
|
|
465
|
+
isMention: isMention || isDM,
|
|
466
|
+
author: {
|
|
467
|
+
userId: msg.author.id,
|
|
468
|
+
userName: msg.author.username,
|
|
469
|
+
fullName: msg.author.displayName || msg.author.username,
|
|
470
|
+
isBot: msg.author.bot,
|
|
471
|
+
isMe: false,
|
|
472
|
+
},
|
|
473
|
+
metadata: {
|
|
474
|
+
dateSent: msg.createdAt,
|
|
475
|
+
edited: msg.editedAt !== null,
|
|
476
|
+
// Store reply flag and platform IDs in metadata for downstream consumers
|
|
477
|
+
...({
|
|
478
|
+
isReplyToBot,
|
|
479
|
+
replyToMessageId: msg.reference?.messageId ?? undefined,
|
|
480
|
+
platformMessageId: msg.id,
|
|
481
|
+
} as Record<string, unknown>),
|
|
482
|
+
},
|
|
483
|
+
attachments: msg.attachments.map((a) => ({
|
|
484
|
+
type: this.getAttachmentType(a.contentType),
|
|
485
|
+
url: a.url,
|
|
486
|
+
name: a.name,
|
|
487
|
+
size: a.size,
|
|
488
|
+
mimeType: a.contentType || undefined,
|
|
489
|
+
})),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
this.chat?.processMessage(this, fullThreadId, incoming);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private postableToText(message: AdapterPostableMessage): string {
|
|
496
|
+
if (typeof message === "string") return message;
|
|
497
|
+
if (typeof message === "object" && message !== null) {
|
|
498
|
+
if ("markdown" in message && typeof message.markdown === "string")
|
|
499
|
+
return message.markdown;
|
|
500
|
+
if ("ast" in message && message.ast)
|
|
501
|
+
return stringifyMarkdown(message.ast);
|
|
502
|
+
if ("raw" in message && typeof message.raw === "string")
|
|
503
|
+
return message.raw;
|
|
504
|
+
}
|
|
505
|
+
return "";
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private getAttachmentType(
|
|
509
|
+
mimeType: string | null,
|
|
510
|
+
): "image" | "video" | "audio" | "file" {
|
|
511
|
+
if (!mimeType) return "file";
|
|
512
|
+
if (mimeType.startsWith("image/")) return "image";
|
|
513
|
+
if (mimeType.startsWith("video/")) return "video";
|
|
514
|
+
if (mimeType.startsWith("audio/")) return "audio";
|
|
515
|
+
return "file";
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export function createDiscordNativeAdapter(
|
|
520
|
+
options?: Partial<DiscordNativeAdapterOptions>,
|
|
521
|
+
): DiscordNativeAdapter {
|
|
522
|
+
const botToken = options?.botToken || process.env.MERCURY_DISCORD_BOT_TOKEN;
|
|
523
|
+
|
|
524
|
+
if (!botToken) {
|
|
525
|
+
throw new Error(
|
|
526
|
+
"Discord native adapter requires MERCURY_DISCORD_BOT_TOKEN environment variable",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return new DiscordNativeAdapter({
|
|
531
|
+
botToken,
|
|
532
|
+
userName: options?.userName,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord adapter factory for the webhook-based (serverless) adapter.
|
|
3
|
+
*
|
|
4
|
+
* Note: Mercury primarily uses the native adapter (discord-native.ts) with
|
|
5
|
+
* persistent WebSocket. This factory is kept for potential fallback use.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createDiscordAdapter as createBaseDiscordAdapter,
|
|
10
|
+
type DiscordAdapter,
|
|
11
|
+
type DiscordThreadId,
|
|
12
|
+
} from "@chat-adapter/discord";
|
|
13
|
+
import { logger } from "../logger.js";
|
|
14
|
+
|
|
15
|
+
export type { DiscordAdapter, DiscordThreadId };
|
|
16
|
+
|
|
17
|
+
export function createDiscordAdapter(options?: {
|
|
18
|
+
userName?: string;
|
|
19
|
+
}): DiscordAdapter {
|
|
20
|
+
const botToken = process.env.MERCURY_DISCORD_BOT_TOKEN;
|
|
21
|
+
const publicKey = process.env.MERCURY_DISCORD_PUBLIC_KEY;
|
|
22
|
+
const applicationId = process.env.MERCURY_DISCORD_APPLICATION_ID;
|
|
23
|
+
|
|
24
|
+
if (!botToken || !publicKey || !applicationId) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"Discord adapter requires MERCURY_DISCORD_BOT_TOKEN, MERCURY_DISCORD_PUBLIC_KEY, and MERCURY_DISCORD_APPLICATION_ID",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
logger.info("Creating Discord adapter", { applicationId });
|
|
31
|
+
|
|
32
|
+
return createBaseDiscordAdapter({
|
|
33
|
+
botToken,
|
|
34
|
+
publicKey,
|
|
35
|
+
applicationId,
|
|
36
|
+
userName: options?.userName,
|
|
37
|
+
});
|
|
38
|
+
}
|