jeopi-cli 16.6.11 → 16.6.13
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/CHANGELOG.md +22 -0
- package/README.md +34 -9
- package/dist/cli.js +3662 -3649
- package/dist/types/cli/update-cli.d.ts +9 -0
- package/dist/types/main.d.ts +1 -1
- package/dist/types/messenger/activity.d.ts +7 -0
- package/dist/types/messenger/artifacts.d.ts +12 -0
- package/dist/types/messenger/gateway.d.ts +5 -1
- package/dist/types/messenger/live-session.d.ts +4 -0
- package/dist/types/messenger/types.d.ts +18 -1
- package/dist/types/session/agent-session.d.ts +3 -1
- package/package.json +12 -12
- package/src/cli/update-cli.ts +13 -3
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +2 -0
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +8 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +12 -2
- package/src/messenger/activity.ts +24 -0
- package/src/messenger/artifacts.ts +167 -0
- package/src/messenger/config.ts +14 -2
- package/src/messenger/discord.ts +330 -22
- package/src/messenger/gateway.ts +225 -8
- package/src/messenger/http.ts +1 -0
- package/src/messenger/live-session.ts +219 -24
- package/src/messenger/session.ts +14 -2
- package/src/messenger/types.ts +23 -1
- package/src/modes/controllers/input-controller.ts +26 -4
- package/src/prompts/system/messenger-artifacts.md +4 -0
- package/src/session/agent-session.ts +14 -10
- package/src/slash-commands/builtin-registry.ts +25 -2
package/src/main.ts
CHANGED
|
@@ -296,7 +296,11 @@ export function buildModelScopeNotification(
|
|
|
296
296
|
export async function submitInteractiveInput(
|
|
297
297
|
mode: Pick<
|
|
298
298
|
InteractiveMode,
|
|
299
|
-
|
|
299
|
+
| "markPendingSubmissionStarted"
|
|
300
|
+
| "finishPendingSubmission"
|
|
301
|
+
| "showError"
|
|
302
|
+
| "checkShutdownRequested"
|
|
303
|
+
| "liveDiscordBridge"
|
|
300
304
|
>,
|
|
301
305
|
session: Pick<AgentSession, "prompt" | "promptCustomMessage" | "isStreaming">,
|
|
302
306
|
input: SubmittedUserInput,
|
|
@@ -346,7 +350,13 @@ export async function submitInteractiveInput(
|
|
|
346
350
|
userInitiated: input.userInitiated,
|
|
347
351
|
});
|
|
348
352
|
} else {
|
|
349
|
-
|
|
353
|
+
const submit = () => session.prompt(input.text, { images: input.images, streamingBehavior });
|
|
354
|
+
const bridge = mode.liveDiscordBridge;
|
|
355
|
+
await (input.streamingBehavior === "steer" && bridge?.active
|
|
356
|
+
? bridge.withLocalTurn(input.text, onPreparedUserMessage =>
|
|
357
|
+
session.prompt(input.text, { images: input.images, streamingBehavior, onPreparedUserMessage }),
|
|
358
|
+
)
|
|
359
|
+
: submit());
|
|
350
360
|
}
|
|
351
361
|
} catch (error: unknown) {
|
|
352
362
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AgentSessionEvent } from "../session/agent-session";
|
|
2
|
+
import type { MessengerActivityReporter } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Derive one-line progress from session events: the model's declared tool intent (falling back to the
|
|
6
|
+
* tool name) and long-running lifecycle notices. Raw tool arguments, results and errors never surface.
|
|
7
|
+
*/
|
|
8
|
+
export function createMessengerActivityTracker(report: MessengerActivityReporter): (event: AgentSessionEvent) => void {
|
|
9
|
+
let step = 0;
|
|
10
|
+
return event => {
|
|
11
|
+
switch (event.type) {
|
|
12
|
+
case "tool_execution_start":
|
|
13
|
+
step++;
|
|
14
|
+
report(`Step ${step} · ${event.intent ?? `Using ${event.toolName}`}`);
|
|
15
|
+
return;
|
|
16
|
+
case "auto_compaction_start":
|
|
17
|
+
report("Compacting context");
|
|
18
|
+
return;
|
|
19
|
+
case "auto_retry_start":
|
|
20
|
+
report(`Retrying after a provider error (${event.attempt}/${event.maxAttempts})`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import * as fsConstants from "node:fs";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { prompt } from "jeopi-utils";
|
|
5
|
+
import artifactPrompt from "../prompts/system/messenger-artifacts.md" with { type: "text" };
|
|
6
|
+
import { messengerConversationKey } from "./gateway";
|
|
7
|
+
import type { MessengerMessage } from "./types";
|
|
8
|
+
|
|
9
|
+
const MAX_ARTIFACT_SIZE = 8 * 1024 * 1024;
|
|
10
|
+
const MAX_ARTIFACTS = 10;
|
|
11
|
+
|
|
12
|
+
export function messengerArtifactDirectory(root: string, message: MessengerMessage): string {
|
|
13
|
+
const route = new Bun.CryptoHasher("sha256").update(messengerConversationKey(message)).digest("hex");
|
|
14
|
+
return path.join(root, route);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function prepareMessengerArtifactDirectory(root: string, message: MessengerMessage): Promise<string> {
|
|
18
|
+
const rootStat = await fs.lstat(root);
|
|
19
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("Invalid artifact directory");
|
|
20
|
+
const directory = messengerArtifactDirectory(root, message);
|
|
21
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
22
|
+
const stat = await fs.lstat(directory);
|
|
23
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Invalid artifact directory");
|
|
24
|
+
return directory;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function withAbort<T>(operation: () => Promise<T>, signal: AbortSignal): Promise<T> {
|
|
28
|
+
if (signal.aborted) throw new Error("Attachment download aborted");
|
|
29
|
+
const stopped = Promise.withResolvers<never>();
|
|
30
|
+
const abort = (): void => stopped.reject(new Error("Attachment download aborted"));
|
|
31
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
32
|
+
try {
|
|
33
|
+
return await Promise.race([operation(), stopped.promise]);
|
|
34
|
+
} finally {
|
|
35
|
+
signal.removeEventListener("abort", abort);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function downloadMessengerAttachments(
|
|
40
|
+
directory: string,
|
|
41
|
+
message: MessengerMessage,
|
|
42
|
+
signal: AbortSignal,
|
|
43
|
+
fetchImpl: typeof fetch = fetch,
|
|
44
|
+
timeoutMs = 30_000,
|
|
45
|
+
): Promise<string[]> {
|
|
46
|
+
const attachments = message.attachments ?? [];
|
|
47
|
+
if (attachments.length > 5) throw new Error("Too many attachments");
|
|
48
|
+
const paths: string[] = [];
|
|
49
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
50
|
+
const url = new URL(attachment.url);
|
|
51
|
+
if (
|
|
52
|
+
url.protocol !== "https:" ||
|
|
53
|
+
url.hostname !== "cdn.discordapp.com" ||
|
|
54
|
+
url.port ||
|
|
55
|
+
url.username ||
|
|
56
|
+
url.password ||
|
|
57
|
+
!url.pathname.startsWith("/attachments/")
|
|
58
|
+
)
|
|
59
|
+
throw new Error("Invalid attachment URL");
|
|
60
|
+
if (!Number.isSafeInteger(attachment.size) || attachment.size < 0 || attachment.size > MAX_ARTIFACT_SIZE)
|
|
61
|
+
throw new Error("Invalid attachment size");
|
|
62
|
+
const downloadSignal = AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
|
|
63
|
+
const response = await withAbort(
|
|
64
|
+
() => fetchImpl(url, { signal: downloadSignal, redirect: "error" }),
|
|
65
|
+
downloadSignal,
|
|
66
|
+
);
|
|
67
|
+
if (!response.ok || !response.body) throw new Error("Attachment download failed");
|
|
68
|
+
const reader = response.body.getReader();
|
|
69
|
+
const chunks: Uint8Array[] = [];
|
|
70
|
+
let size = 0;
|
|
71
|
+
try {
|
|
72
|
+
while (true) {
|
|
73
|
+
const { done, value } = await withAbort(() => reader.read(), downloadSignal);
|
|
74
|
+
if (done) break;
|
|
75
|
+
size += value.byteLength;
|
|
76
|
+
if (size > MAX_ARTIFACT_SIZE || size > attachment.size) throw new Error("Attachment exceeds declared size");
|
|
77
|
+
chunks.push(value);
|
|
78
|
+
}
|
|
79
|
+
} finally {
|
|
80
|
+
void reader.cancel().catch(() => {});
|
|
81
|
+
}
|
|
82
|
+
if (size !== attachment.size) throw new Error("Attachment size mismatch");
|
|
83
|
+
const safeName =
|
|
84
|
+
path
|
|
85
|
+
.basename(attachment.filename)
|
|
86
|
+
.replace(/[^a-zA-Z0-9._-]/gu, "_")
|
|
87
|
+
.slice(0, 100) || "attachment";
|
|
88
|
+
const id = new Bun.CryptoHasher("sha256").update(message.messageId).digest("hex");
|
|
89
|
+
const file = path.join(directory, `input-${id}-${index}-${safeName}`);
|
|
90
|
+
await fs.writeFile(file, Buffer.concat(chunks), { flag: "wx", mode: 0o600 });
|
|
91
|
+
paths.push(file);
|
|
92
|
+
}
|
|
93
|
+
return paths;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function messengerArtifactPrompt(
|
|
97
|
+
root: string | undefined,
|
|
98
|
+
message: MessengerMessage,
|
|
99
|
+
signal: AbortSignal,
|
|
100
|
+
): Promise<string> {
|
|
101
|
+
if (!root) {
|
|
102
|
+
if (message.attachments?.length) throw new Error("Discord attachments are not enabled");
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
const directory = await prepareMessengerArtifactDirectory(root, message);
|
|
106
|
+
const inputs = await downloadMessengerAttachments(directory, message, signal);
|
|
107
|
+
return prompt.render(artifactPrompt, { directory, inputs: inputs.join(", ") });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function readMessengerArtifacts(
|
|
111
|
+
root: string,
|
|
112
|
+
message: MessengerMessage,
|
|
113
|
+
text: string,
|
|
114
|
+
): Promise<{
|
|
115
|
+
text: string;
|
|
116
|
+
files: { name: string; data: Uint8Array }[];
|
|
117
|
+
}> {
|
|
118
|
+
const names: string[] = [];
|
|
119
|
+
const cleaned = text
|
|
120
|
+
.replace(/^\s*\[artifact:([^\]\r\n]+)\]\s*$/gmu, (_match, name: string) => {
|
|
121
|
+
names.push(name);
|
|
122
|
+
return "";
|
|
123
|
+
})
|
|
124
|
+
.trim();
|
|
125
|
+
if (names.length > MAX_ARTIFACTS) throw new Error("Too many output files");
|
|
126
|
+
if (!names.length) return { text, files: [] };
|
|
127
|
+
const directory = await prepareMessengerArtifactDirectory(root, message);
|
|
128
|
+
const files: { name: string; data: Uint8Array }[] = [];
|
|
129
|
+
for (const name of names) {
|
|
130
|
+
if (
|
|
131
|
+
!name ||
|
|
132
|
+
path.isAbsolute(name) ||
|
|
133
|
+
name.includes("\\") ||
|
|
134
|
+
name.split("/").some(part => !part || part === "." || part === "..")
|
|
135
|
+
) {
|
|
136
|
+
throw new Error("Invalid output file path");
|
|
137
|
+
}
|
|
138
|
+
let current = path.resolve(directory);
|
|
139
|
+
for (const part of name.split("/")) {
|
|
140
|
+
current = path.join(current, part);
|
|
141
|
+
const entry = await fs.lstat(current);
|
|
142
|
+
if (entry.isSymbolicLink()) throw new Error("Output file symlinks are not allowed");
|
|
143
|
+
}
|
|
144
|
+
if (!(await fs.lstat(current)).isFile()) throw new Error("Output file is not a regular file");
|
|
145
|
+
const handle = await fs.open(
|
|
146
|
+
current,
|
|
147
|
+
fsConstants.constants.O_RDONLY | fsConstants.constants.O_NOFOLLOW | fsConstants.constants.O_NONBLOCK,
|
|
148
|
+
);
|
|
149
|
+
try {
|
|
150
|
+
const stat = await handle.stat();
|
|
151
|
+
if (!stat.isFile() || stat.size > MAX_ARTIFACT_SIZE)
|
|
152
|
+
throw new Error("Output file is invalid or exceeds 8 MiB");
|
|
153
|
+
const data = Buffer.alloc(stat.size + 1);
|
|
154
|
+
let offset = 0;
|
|
155
|
+
while (offset < data.length) {
|
|
156
|
+
const { bytesRead } = await handle.read(data, offset, data.length - offset, offset);
|
|
157
|
+
if (!bytesRead) break;
|
|
158
|
+
offset += bytesRead;
|
|
159
|
+
}
|
|
160
|
+
if (offset !== stat.size) throw new Error("Output file changed while being read");
|
|
161
|
+
files.push({ name: path.basename(name), data: data.subarray(0, offset) });
|
|
162
|
+
} finally {
|
|
163
|
+
await handle.close();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { text: cleaned, files };
|
|
167
|
+
}
|
package/src/messenger/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
1
2
|
import * as path from "node:path";
|
|
2
3
|
import type { MessengerConfig, MessengerPlatform, MessengerPlatformConfig } from "./types";
|
|
3
4
|
|
|
@@ -51,7 +52,9 @@ export function parseMessengerConfig(value: unknown, baseDir: string): Messenger
|
|
|
51
52
|
item,
|
|
52
53
|
platform === "slack"
|
|
53
54
|
? ["tokenEnv", "appTokenEnv", "allowedUserIds", "allowedChannelIds"]
|
|
54
|
-
:
|
|
55
|
+
: platform === "discord"
|
|
56
|
+
? ["tokenEnv", "allowedUserIds", "allowedChannelIds", "artifactDir"]
|
|
57
|
+
: ["tokenEnv", "allowedUserIds", "allowedChannelIds"],
|
|
55
58
|
`${platform} config`,
|
|
56
59
|
);
|
|
57
60
|
const config: MessengerPlatformConfig = {
|
|
@@ -62,6 +65,8 @@ export function parseMessengerConfig(value: unknown, baseDir: string): Messenger
|
|
|
62
65
|
config.appTokenEnv = envName(item.appTokenEnv, "JEOPI_SLACK_APP_TOKEN", "slack.appTokenEnv");
|
|
63
66
|
if (item.allowedChannelIds !== undefined)
|
|
64
67
|
config.allowedChannelIds = ids(item.allowedChannelIds, `${platform}.allowedChannelIds`);
|
|
68
|
+
if (platform === "discord" && item.artifactDir !== undefined)
|
|
69
|
+
config.artifactDir = path.resolve(baseDir, string(item.artifactDir, "discord.artifactDir"));
|
|
65
70
|
platforms[platform] = config;
|
|
66
71
|
}
|
|
67
72
|
const toolNames = input.toolNames === undefined ? [] : input.toolNames;
|
|
@@ -90,7 +95,14 @@ export async function loadMessengerConfig(file: string): Promise<MessengerConfig
|
|
|
90
95
|
} catch {
|
|
91
96
|
throw new Error("Cannot read messenger configuration as JSON");
|
|
92
97
|
}
|
|
93
|
-
|
|
98
|
+
const config = parseMessengerConfig(value, path.dirname(path.resolve(file)));
|
|
99
|
+
const artifactDir = config.platforms.discord?.artifactDir;
|
|
100
|
+
if (artifactDir) {
|
|
101
|
+
const stat = await fs.lstat(artifactDir).catch(() => undefined);
|
|
102
|
+
if (!stat?.isDirectory() || stat.isSymbolicLink())
|
|
103
|
+
throw new Error("discord.artifactDir must be an existing directory, not a symlink");
|
|
104
|
+
}
|
|
105
|
+
return config;
|
|
94
106
|
}
|
|
95
107
|
|
|
96
108
|
export function validateMessengerEnvironment(
|
package/src/messenger/discord.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isRecord, logger } from "jeopi-utils";
|
|
2
|
-
import {
|
|
2
|
+
import { readMessengerArtifacts } from "./artifacts";
|
|
3
|
+
import { MessengerHttpError, messengerFetchJson } from "./http";
|
|
3
4
|
import type { MessengerAdapter, MessengerMessage, MessengerPlatformConfig, MessengerTransportDeps } from "./types";
|
|
4
5
|
|
|
5
6
|
// Protocol: https://docs.discord.com/developers/events/gateway
|
|
@@ -9,6 +10,38 @@ const API = "https://discord.com/api/v10";
|
|
|
9
10
|
const INTENTS = (1 << 9) | (1 << 12) | (1 << 15);
|
|
10
11
|
const PERMANENT_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
|
|
11
12
|
|
|
13
|
+
// Discord strips boundary whitespace. Invisible guards preserve it in message content;
|
|
14
|
+
// they are part of the delivered text, and consume one UTF-16 unit each.
|
|
15
|
+
const DISCORD_WHITESPACE = /^[\s\u0085\u001c-\u001f]$/u;
|
|
16
|
+
const DISCORD_BOUNDARY_GUARD = "\u200b";
|
|
17
|
+
// SUPPRESS_EMBEDS | SUPPRESS_NOTIFICATIONS: a status line never unfurls links or pings on creation.
|
|
18
|
+
const DISCORD_ACTIVITY_FLAGS = (1 << 2) | (1 << 12);
|
|
19
|
+
|
|
20
|
+
function splitDiscordText(text: string): { chunks: string[]; needsAttachment: boolean } {
|
|
21
|
+
const chunks: string[] = [];
|
|
22
|
+
let needsAttachment = false;
|
|
23
|
+
for (let start = 0; start < text.length; ) {
|
|
24
|
+
const leadingGuard = DISCORD_WHITESPACE.test(text[start]) ? DISCORD_BOUNDARY_GUARD : "";
|
|
25
|
+
let fallback = 0;
|
|
26
|
+
let end = Math.min(start + 2_000 - leadingGuard.length, text.length);
|
|
27
|
+
for (; end > start; end--) {
|
|
28
|
+
const last = text.charCodeAt(end - 1);
|
|
29
|
+
if (end < text.length && last >= 0xd800 && last <= 0xdbff) continue;
|
|
30
|
+
const trailingWhitespace = DISCORD_WHITESPACE.test(text[end - 1]);
|
|
31
|
+
if (end - start + leadingGuard.length + Number(trailingWhitespace) > 2_000) continue;
|
|
32
|
+
if (!fallback) fallback = end;
|
|
33
|
+
if (end === text.length || (!trailingWhitespace && !DISCORD_WHITESPACE.test(text[end]))) break;
|
|
34
|
+
}
|
|
35
|
+
if (end === start) end = fallback;
|
|
36
|
+
const chunk = text.slice(start, end);
|
|
37
|
+
const trailingGuard = DISCORD_WHITESPACE.test(chunk[chunk.length - 1]) ? DISCORD_BOUNDARY_GUARD : "";
|
|
38
|
+
if (leadingGuard || trailingGuard) needsAttachment = true;
|
|
39
|
+
chunks.push(`${leadingGuard}${chunk}${trailingGuard}`);
|
|
40
|
+
start = end;
|
|
41
|
+
}
|
|
42
|
+
return { chunks, needsAttachment };
|
|
43
|
+
}
|
|
44
|
+
|
|
12
45
|
class DiscordConfigurationError extends Error {}
|
|
13
46
|
|
|
14
47
|
export interface DiscordClock {
|
|
@@ -89,14 +122,24 @@ function normalizeMessage(value: unknown, accountId: string): MessengerMessage |
|
|
|
89
122
|
typeof value.author.id !== "string" ||
|
|
90
123
|
!value.author.id ||
|
|
91
124
|
typeof value.content !== "string" ||
|
|
92
|
-
!value.content.trim() ||
|
|
125
|
+
(!value.content.trim() && !(Array.isArray(value.attachments) && value.attachments.length > 0)) ||
|
|
93
126
|
(value.type !== undefined && value.type !== 0 && value.type !== 19)
|
|
94
127
|
)
|
|
95
128
|
return;
|
|
96
129
|
const mentioned =
|
|
97
130
|
Array.isArray(value.mentions) && value.mentions.some(user => isRecord(user) && user.id === accountId);
|
|
98
131
|
const text = value.content.replaceAll(`<@${accountId}>`, "").replaceAll(`<@!${accountId}>`, "").trim();
|
|
99
|
-
|
|
132
|
+
const attachments = Array.isArray(value.attachments)
|
|
133
|
+
? value.attachments.map(item =>
|
|
134
|
+
isRecord(item) &&
|
|
135
|
+
typeof item.url === "string" &&
|
|
136
|
+
typeof item.filename === "string" &&
|
|
137
|
+
typeof item.size === "number"
|
|
138
|
+
? { url: item.url, filename: item.filename, size: item.size }
|
|
139
|
+
: { url: "", filename: "", size: -1 },
|
|
140
|
+
)
|
|
141
|
+
: [];
|
|
142
|
+
if (!text && attachments.length === 0) return;
|
|
100
143
|
return {
|
|
101
144
|
platform: "discord",
|
|
102
145
|
accountId,
|
|
@@ -105,6 +148,7 @@ function normalizeMessage(value: unknown, accountId: string): MessengerMessage |
|
|
|
105
148
|
messageId: value.id,
|
|
106
149
|
text,
|
|
107
150
|
direct: value.guild_id === undefined,
|
|
151
|
+
attachments,
|
|
108
152
|
mentioned,
|
|
109
153
|
};
|
|
110
154
|
}
|
|
@@ -283,6 +327,15 @@ export function createDiscordAdapter(
|
|
|
283
327
|
): MessengerAdapter {
|
|
284
328
|
const token = env[config.tokenEnv]?.trim();
|
|
285
329
|
const clock = deps.clock ?? defaultClock;
|
|
330
|
+
const createdThreads = new Map<string, { parentChannelId: string; senderId: string }>();
|
|
331
|
+
const rememberThread = (threadId: string, parentChannelId: string, senderId: string): void => {
|
|
332
|
+
createdThreads.set(threadId, { parentChannelId, senderId });
|
|
333
|
+
if (createdThreads.size > 10_000) {
|
|
334
|
+
const oldest = createdThreads.keys().next().value;
|
|
335
|
+
if (oldest !== undefined) createdThreads.delete(oldest);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
const activityMessages = new Map<string, string>();
|
|
286
339
|
const headers = (): Record<string, string> => {
|
|
287
340
|
if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
|
|
288
341
|
return { Authorization: `Bot ${token}`, "Content-Type": "application/json" };
|
|
@@ -319,12 +372,54 @@ export function createDiscordAdapter(
|
|
|
319
372
|
}
|
|
320
373
|
initialUrl = gatewayUrl(gateway.url);
|
|
321
374
|
}
|
|
375
|
+
const deliver = async (message: MessengerMessage): Promise<void> => {
|
|
376
|
+
const thread = createdThreads.get(message.channelId);
|
|
377
|
+
if (thread && thread.senderId === message.senderId) {
|
|
378
|
+
await onMessage({
|
|
379
|
+
...message,
|
|
380
|
+
threadId: message.channelId,
|
|
381
|
+
parentChannelId: thread.parentChannelId,
|
|
382
|
+
});
|
|
383
|
+
} else if (
|
|
384
|
+
!message.direct &&
|
|
385
|
+
message.mentioned &&
|
|
386
|
+
config.allowedUserIds.includes(message.senderId) &&
|
|
387
|
+
config.allowedChannelIds &&
|
|
388
|
+
!config.allowedChannelIds.includes(message.channelId)
|
|
389
|
+
) {
|
|
390
|
+
try {
|
|
391
|
+
const channel = await messengerFetchJson(
|
|
392
|
+
`${API}/channels/${encodeURIComponent(message.channelId)}`,
|
|
393
|
+
{ headers: headers() },
|
|
394
|
+
signal,
|
|
395
|
+
deps.fetch,
|
|
396
|
+
);
|
|
397
|
+
if (
|
|
398
|
+
isRecord(channel) &&
|
|
399
|
+
typeof channel.type === "number" &&
|
|
400
|
+
[10, 11, 12].includes(channel.type) &&
|
|
401
|
+
typeof channel.parent_id === "string" &&
|
|
402
|
+
channel.parent_id
|
|
403
|
+
) {
|
|
404
|
+
await onMessage({
|
|
405
|
+
...message,
|
|
406
|
+
threadId: message.channelId,
|
|
407
|
+
parentChannelId: channel.parent_id,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
} catch {
|
|
411
|
+
if (!signal.aborted) logger.warn("Discord thread parent lookup failed");
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
await onMessage(message);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
322
417
|
await connectGateway(
|
|
323
418
|
session.url ?? initialUrl,
|
|
324
419
|
token!,
|
|
325
420
|
accountId,
|
|
326
421
|
session,
|
|
327
|
-
|
|
422
|
+
deliver,
|
|
328
423
|
signal,
|
|
329
424
|
deps.WebSocket ?? WebSocket,
|
|
330
425
|
clock,
|
|
@@ -357,25 +452,238 @@ export function createDiscordAdapter(
|
|
|
357
452
|
delay = Math.min(delay * 2, 30_000);
|
|
358
453
|
}
|
|
359
454
|
},
|
|
455
|
+
async prepare(message, signal) {
|
|
456
|
+
if (message.direct) return message;
|
|
457
|
+
if (message.threadId) {
|
|
458
|
+
if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId, message.senderId);
|
|
459
|
+
return message;
|
|
460
|
+
}
|
|
461
|
+
const channel = await messengerFetchJson(
|
|
462
|
+
`${API}/channels/${encodeURIComponent(message.channelId)}`,
|
|
463
|
+
{ headers: headers() },
|
|
464
|
+
signal,
|
|
465
|
+
deps.fetch,
|
|
466
|
+
);
|
|
467
|
+
if (!isRecord(channel) || typeof channel.type !== "number")
|
|
468
|
+
throw new Error("Discord channel type unavailable");
|
|
469
|
+
if ([10, 11, 12].includes(channel.type)) {
|
|
470
|
+
if (typeof channel.parent_id !== "string" || !channel.parent_id)
|
|
471
|
+
throw new Error("Discord thread parent unavailable");
|
|
472
|
+
rememberThread(message.channelId, channel.parent_id, message.senderId);
|
|
473
|
+
return { ...message, threadId: message.channelId, parentChannelId: channel.parent_id };
|
|
474
|
+
}
|
|
475
|
+
if (channel.type !== 0 && channel.type !== 5) throw new Error("Discord channel cannot host a reply thread");
|
|
476
|
+
const thread = await messengerFetchJson(
|
|
477
|
+
`${API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/threads`,
|
|
478
|
+
{ method: "POST", headers: headers(), body: JSON.stringify({ name: `Request ${message.messageId}` }) },
|
|
479
|
+
signal,
|
|
480
|
+
deps.fetch,
|
|
481
|
+
);
|
|
482
|
+
if (!isRecord(thread) || typeof thread.id !== "string" || !thread.id)
|
|
483
|
+
throw new Error("Discord thread creation returned no ID");
|
|
484
|
+
rememberThread(thread.id, message.channelId, message.senderId);
|
|
485
|
+
return { ...message, threadId: thread.id };
|
|
486
|
+
},
|
|
487
|
+
async status(message, state, signal) {
|
|
488
|
+
const base = `${API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/reactions`;
|
|
489
|
+
const processing = encodeURIComponent("⏳");
|
|
490
|
+
if (state !== "processing") {
|
|
491
|
+
try {
|
|
492
|
+
await messengerFetchJson(
|
|
493
|
+
`${base}/${processing}/@me`,
|
|
494
|
+
{ method: "DELETE", headers: headers() },
|
|
495
|
+
signal,
|
|
496
|
+
deps.fetch,
|
|
497
|
+
);
|
|
498
|
+
} catch {
|
|
499
|
+
logger.warn("Discord processing reaction removal failed");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const emoji = state === "processing" ? "⏳" : state === "success" ? "✅" : "❌";
|
|
503
|
+
await messengerFetchJson(
|
|
504
|
+
`${base}/${encodeURIComponent(emoji)}/@me`,
|
|
505
|
+
{ method: "PUT", headers: headers() },
|
|
506
|
+
signal,
|
|
507
|
+
deps.fetch,
|
|
508
|
+
);
|
|
509
|
+
},
|
|
510
|
+
async activity(message, text, signal) {
|
|
511
|
+
const channelId = message.threadId ?? message.channelId;
|
|
512
|
+
const key = JSON.stringify([channelId, message.messageId]);
|
|
513
|
+
const messages = `${API}/channels/${encodeURIComponent(channelId)}/messages`;
|
|
514
|
+
const statusId = activityMessages.get(key);
|
|
515
|
+
if (text === undefined) {
|
|
516
|
+
if (!statusId) return;
|
|
517
|
+
try {
|
|
518
|
+
await messengerFetchJson(
|
|
519
|
+
`${messages}/${encodeURIComponent(statusId)}`,
|
|
520
|
+
{ method: "DELETE", headers: headers() },
|
|
521
|
+
signal,
|
|
522
|
+
deps.fetch,
|
|
523
|
+
);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
// Keep the ID so the caller can retry; a missing message is already removed.
|
|
526
|
+
if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
|
|
527
|
+
}
|
|
528
|
+
activityMessages.delete(key);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const content = `⏳ ${text}`;
|
|
532
|
+
if (statusId) {
|
|
533
|
+
try {
|
|
534
|
+
// Edits re-parse mentions with default allowances unless allowed_mentions is repeated.
|
|
535
|
+
await messengerFetchJson(
|
|
536
|
+
`${messages}/${encodeURIComponent(statusId)}`,
|
|
537
|
+
{
|
|
538
|
+
method: "PATCH",
|
|
539
|
+
headers: headers(),
|
|
540
|
+
body: JSON.stringify({ content, allowed_mentions: { parse: [] } }),
|
|
541
|
+
},
|
|
542
|
+
signal,
|
|
543
|
+
deps.fetch,
|
|
544
|
+
);
|
|
545
|
+
return;
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
|
|
548
|
+
activityMessages.delete(key);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const created = await messengerFetchJson(
|
|
552
|
+
messages,
|
|
553
|
+
{
|
|
554
|
+
method: "POST",
|
|
555
|
+
headers: headers(),
|
|
556
|
+
body: JSON.stringify({ content, allowed_mentions: { parse: [] }, flags: DISCORD_ACTIVITY_FLAGS }),
|
|
557
|
+
},
|
|
558
|
+
signal,
|
|
559
|
+
deps.fetch,
|
|
560
|
+
);
|
|
561
|
+
if (!isRecord(created) || typeof created.id !== "string" || !created.id)
|
|
562
|
+
throw new Error("Discord activity message returned no ID");
|
|
563
|
+
activityMessages.set(key, created.id);
|
|
564
|
+
},
|
|
360
565
|
async send(message, text, signal) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
566
|
+
let files: { name: string; data: Uint8Array }[] = [];
|
|
567
|
+
if (config.artifactDir) {
|
|
568
|
+
try {
|
|
569
|
+
const result = await readMessengerArtifacts(config.artifactDir, message, text);
|
|
570
|
+
text = result.text;
|
|
571
|
+
files = result.files;
|
|
572
|
+
} catch {
|
|
573
|
+
await messengerFetchJson(
|
|
574
|
+
`${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
575
|
+
{
|
|
576
|
+
method: "POST",
|
|
577
|
+
headers: headers(),
|
|
578
|
+
body: JSON.stringify({
|
|
579
|
+
content: "File delivery failed: check the output path and size (maximum 8 MiB).",
|
|
580
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
581
|
+
}),
|
|
582
|
+
},
|
|
583
|
+
signal,
|
|
584
|
+
deps.fetch,
|
|
585
|
+
);
|
|
586
|
+
throw new Error("Discord artifact delivery failed");
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const { chunks, needsAttachment } = splitDiscordText(text);
|
|
590
|
+
for (let index = 0; index < chunks.length + Number(needsAttachment); index++) {
|
|
591
|
+
for (let attempt = 0; ; attempt++) {
|
|
592
|
+
try {
|
|
593
|
+
const attachment = index === chunks.length;
|
|
594
|
+
const requestHeaders = headers();
|
|
595
|
+
let body: string | FormData;
|
|
596
|
+
if (attachment) {
|
|
597
|
+
delete requestHeaders["Content-Type"];
|
|
598
|
+
const form = new FormData();
|
|
599
|
+
form.append(
|
|
600
|
+
"payload_json",
|
|
601
|
+
JSON.stringify({ allowed_mentions: { parse: [], replied_user: false } }),
|
|
602
|
+
);
|
|
603
|
+
form.append("files[0]", new Blob([text], { type: "text/plain; charset=utf-8" }), "response.txt");
|
|
604
|
+
body = form;
|
|
605
|
+
} else {
|
|
606
|
+
body = JSON.stringify({
|
|
607
|
+
content: chunks[index],
|
|
608
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
609
|
+
...(index === 0 &&
|
|
610
|
+
message.messageId &&
|
|
611
|
+
(!message.threadId || message.threadId === message.channelId)
|
|
612
|
+
? { message_reference: { message_id: message.messageId, fail_if_not_exists: false } }
|
|
613
|
+
: {}),
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
await messengerFetchJson(
|
|
617
|
+
`${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
618
|
+
{
|
|
619
|
+
method: "POST",
|
|
620
|
+
headers: requestHeaders,
|
|
621
|
+
body,
|
|
622
|
+
},
|
|
623
|
+
signal,
|
|
624
|
+
deps.fetch,
|
|
625
|
+
);
|
|
626
|
+
break;
|
|
627
|
+
} catch (error) {
|
|
628
|
+
if (error instanceof MessengerHttpError && error.status === 429 && attempt < 3 && !signal?.aborted) {
|
|
629
|
+
try {
|
|
630
|
+
await waitForReconnect(
|
|
631
|
+
error.retryAfterMs ?? 1_000,
|
|
632
|
+
signal ?? new AbortController().signal,
|
|
633
|
+
clock,
|
|
634
|
+
);
|
|
635
|
+
if (!signal?.aborted) continue;
|
|
636
|
+
} catch {
|
|
637
|
+
// Cancellation or timer failure stops delivery without replaying prior chunks.
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
logger.warn("Discord reply partially delivered", {
|
|
641
|
+
deliveredChunks: Math.min(index, chunks.length),
|
|
642
|
+
totalChunks: chunks.length,
|
|
643
|
+
...(index === chunks.length ? { attachmentFailed: true } : {}),
|
|
644
|
+
});
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
for (const file of files) {
|
|
650
|
+
const requestHeaders = headers();
|
|
651
|
+
delete requestHeaders["Content-Type"];
|
|
652
|
+
const form = new FormData();
|
|
653
|
+
form.append("payload_json", JSON.stringify({ allowed_mentions: { parse: [], replied_user: false } }));
|
|
654
|
+
form.append("files[0]", new Blob([file.data]), file.name);
|
|
655
|
+
for (let attempt = 0; ; attempt++) {
|
|
656
|
+
try {
|
|
657
|
+
await messengerFetchJson(
|
|
658
|
+
`${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
659
|
+
{ method: "POST", headers: requestHeaders, body: form },
|
|
660
|
+
signal,
|
|
661
|
+
deps.fetch,
|
|
662
|
+
);
|
|
663
|
+
break;
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (error instanceof MessengerHttpError && error.status === 429 && attempt < 3 && !signal?.aborted) {
|
|
666
|
+
await waitForReconnect(error.retryAfterMs ?? 1_000, signal ?? new AbortController().signal, clock);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (!signal?.aborted) {
|
|
670
|
+
await messengerFetchJson(
|
|
671
|
+
`${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
672
|
+
{
|
|
673
|
+
method: "POST",
|
|
674
|
+
headers: headers(),
|
|
675
|
+
body: JSON.stringify({
|
|
676
|
+
content: "File upload failed; some files may not have been delivered.",
|
|
677
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
678
|
+
}),
|
|
679
|
+
},
|
|
680
|
+
signal,
|
|
681
|
+
deps.fetch,
|
|
682
|
+
).catch(() => {});
|
|
683
|
+
}
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
379
687
|
}
|
|
380
688
|
},
|
|
381
689
|
};
|