jeopi-cli 16.6.11 → 16.6.12
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 +12 -0
- package/README.md +34 -9
- package/dist/cli.js +3805 -3797
- package/dist/types/main.d.ts +1 -1
- package/dist/types/messenger/artifacts.d.ts +12 -0
- package/dist/types/messenger/live-session.d.ts +4 -0
- package/dist/types/messenger/types.d.ts +10 -0
- package/dist/types/session/agent-session.d.ts +3 -1
- package/package.json +12 -12
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +12 -2
- package/src/messenger/artifacts.ts +167 -0
- package/src/messenger/config.ts +14 -2
- package/src/messenger/discord.ts +272 -22
- package/src/messenger/gateway.ts +53 -7
- package/src/messenger/http.ts +1 -0
- package/src/messenger/live-session.ts +203 -22
- package/src/messenger/session.ts +8 -1
- package/src/messenger/types.ts +10 -0
- 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,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,36 @@ 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
|
+
|
|
18
|
+
function splitDiscordText(text: string): { chunks: string[]; needsAttachment: boolean } {
|
|
19
|
+
const chunks: string[] = [];
|
|
20
|
+
let needsAttachment = false;
|
|
21
|
+
for (let start = 0; start < text.length; ) {
|
|
22
|
+
const leadingGuard = DISCORD_WHITESPACE.test(text[start]) ? DISCORD_BOUNDARY_GUARD : "";
|
|
23
|
+
let fallback = 0;
|
|
24
|
+
let end = Math.min(start + 2_000 - leadingGuard.length, text.length);
|
|
25
|
+
for (; end > start; end--) {
|
|
26
|
+
const last = text.charCodeAt(end - 1);
|
|
27
|
+
if (end < text.length && last >= 0xd800 && last <= 0xdbff) continue;
|
|
28
|
+
const trailingWhitespace = DISCORD_WHITESPACE.test(text[end - 1]);
|
|
29
|
+
if (end - start + leadingGuard.length + Number(trailingWhitespace) > 2_000) continue;
|
|
30
|
+
if (!fallback) fallback = end;
|
|
31
|
+
if (end === text.length || (!trailingWhitespace && !DISCORD_WHITESPACE.test(text[end]))) break;
|
|
32
|
+
}
|
|
33
|
+
if (end === start) end = fallback;
|
|
34
|
+
const chunk = text.slice(start, end);
|
|
35
|
+
const trailingGuard = DISCORD_WHITESPACE.test(chunk[chunk.length - 1]) ? DISCORD_BOUNDARY_GUARD : "";
|
|
36
|
+
if (leadingGuard || trailingGuard) needsAttachment = true;
|
|
37
|
+
chunks.push(`${leadingGuard}${chunk}${trailingGuard}`);
|
|
38
|
+
start = end;
|
|
39
|
+
}
|
|
40
|
+
return { chunks, needsAttachment };
|
|
41
|
+
}
|
|
42
|
+
|
|
12
43
|
class DiscordConfigurationError extends Error {}
|
|
13
44
|
|
|
14
45
|
export interface DiscordClock {
|
|
@@ -89,14 +120,24 @@ function normalizeMessage(value: unknown, accountId: string): MessengerMessage |
|
|
|
89
120
|
typeof value.author.id !== "string" ||
|
|
90
121
|
!value.author.id ||
|
|
91
122
|
typeof value.content !== "string" ||
|
|
92
|
-
!value.content.trim() ||
|
|
123
|
+
(!value.content.trim() && !(Array.isArray(value.attachments) && value.attachments.length > 0)) ||
|
|
93
124
|
(value.type !== undefined && value.type !== 0 && value.type !== 19)
|
|
94
125
|
)
|
|
95
126
|
return;
|
|
96
127
|
const mentioned =
|
|
97
128
|
Array.isArray(value.mentions) && value.mentions.some(user => isRecord(user) && user.id === accountId);
|
|
98
129
|
const text = value.content.replaceAll(`<@${accountId}>`, "").replaceAll(`<@!${accountId}>`, "").trim();
|
|
99
|
-
|
|
130
|
+
const attachments = Array.isArray(value.attachments)
|
|
131
|
+
? value.attachments.map(item =>
|
|
132
|
+
isRecord(item) &&
|
|
133
|
+
typeof item.url === "string" &&
|
|
134
|
+
typeof item.filename === "string" &&
|
|
135
|
+
typeof item.size === "number"
|
|
136
|
+
? { url: item.url, filename: item.filename, size: item.size }
|
|
137
|
+
: { url: "", filename: "", size: -1 },
|
|
138
|
+
)
|
|
139
|
+
: [];
|
|
140
|
+
if (!text && attachments.length === 0) return;
|
|
100
141
|
return {
|
|
101
142
|
platform: "discord",
|
|
102
143
|
accountId,
|
|
@@ -105,6 +146,7 @@ function normalizeMessage(value: unknown, accountId: string): MessengerMessage |
|
|
|
105
146
|
messageId: value.id,
|
|
106
147
|
text,
|
|
107
148
|
direct: value.guild_id === undefined,
|
|
149
|
+
attachments,
|
|
108
150
|
mentioned,
|
|
109
151
|
};
|
|
110
152
|
}
|
|
@@ -283,6 +325,14 @@ export function createDiscordAdapter(
|
|
|
283
325
|
): MessengerAdapter {
|
|
284
326
|
const token = env[config.tokenEnv]?.trim();
|
|
285
327
|
const clock = deps.clock ?? defaultClock;
|
|
328
|
+
const createdThreads = new Map<string, { parentChannelId: string; senderId: string }>();
|
|
329
|
+
const rememberThread = (threadId: string, parentChannelId: string, senderId: string): void => {
|
|
330
|
+
createdThreads.set(threadId, { parentChannelId, senderId });
|
|
331
|
+
if (createdThreads.size > 10_000) {
|
|
332
|
+
const oldest = createdThreads.keys().next().value;
|
|
333
|
+
if (oldest !== undefined) createdThreads.delete(oldest);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
286
336
|
const headers = (): Record<string, string> => {
|
|
287
337
|
if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
|
|
288
338
|
return { Authorization: `Bot ${token}`, "Content-Type": "application/json" };
|
|
@@ -319,12 +369,54 @@ export function createDiscordAdapter(
|
|
|
319
369
|
}
|
|
320
370
|
initialUrl = gatewayUrl(gateway.url);
|
|
321
371
|
}
|
|
372
|
+
const deliver = async (message: MessengerMessage): Promise<void> => {
|
|
373
|
+
const thread = createdThreads.get(message.channelId);
|
|
374
|
+
if (thread && thread.senderId === message.senderId) {
|
|
375
|
+
await onMessage({
|
|
376
|
+
...message,
|
|
377
|
+
threadId: message.channelId,
|
|
378
|
+
parentChannelId: thread.parentChannelId,
|
|
379
|
+
});
|
|
380
|
+
} else if (
|
|
381
|
+
!message.direct &&
|
|
382
|
+
message.mentioned &&
|
|
383
|
+
config.allowedUserIds.includes(message.senderId) &&
|
|
384
|
+
config.allowedChannelIds &&
|
|
385
|
+
!config.allowedChannelIds.includes(message.channelId)
|
|
386
|
+
) {
|
|
387
|
+
try {
|
|
388
|
+
const channel = await messengerFetchJson(
|
|
389
|
+
`${API}/channels/${encodeURIComponent(message.channelId)}`,
|
|
390
|
+
{ headers: headers() },
|
|
391
|
+
signal,
|
|
392
|
+
deps.fetch,
|
|
393
|
+
);
|
|
394
|
+
if (
|
|
395
|
+
isRecord(channel) &&
|
|
396
|
+
typeof channel.type === "number" &&
|
|
397
|
+
[10, 11, 12].includes(channel.type) &&
|
|
398
|
+
typeof channel.parent_id === "string" &&
|
|
399
|
+
channel.parent_id
|
|
400
|
+
) {
|
|
401
|
+
await onMessage({
|
|
402
|
+
...message,
|
|
403
|
+
threadId: message.channelId,
|
|
404
|
+
parentChannelId: channel.parent_id,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
if (!signal.aborted) logger.warn("Discord thread parent lookup failed");
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
await onMessage(message);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
322
414
|
await connectGateway(
|
|
323
415
|
session.url ?? initialUrl,
|
|
324
416
|
token!,
|
|
325
417
|
accountId,
|
|
326
418
|
session,
|
|
327
|
-
|
|
419
|
+
deliver,
|
|
328
420
|
signal,
|
|
329
421
|
deps.WebSocket ?? WebSocket,
|
|
330
422
|
clock,
|
|
@@ -357,25 +449,183 @@ export function createDiscordAdapter(
|
|
|
357
449
|
delay = Math.min(delay * 2, 30_000);
|
|
358
450
|
}
|
|
359
451
|
},
|
|
452
|
+
async prepare(message, signal) {
|
|
453
|
+
if (message.direct) return message;
|
|
454
|
+
if (message.threadId) {
|
|
455
|
+
if (message.parentChannelId) rememberThread(message.threadId, message.parentChannelId, message.senderId);
|
|
456
|
+
return message;
|
|
457
|
+
}
|
|
458
|
+
const channel = await messengerFetchJson(
|
|
459
|
+
`${API}/channels/${encodeURIComponent(message.channelId)}`,
|
|
460
|
+
{ headers: headers() },
|
|
461
|
+
signal,
|
|
462
|
+
deps.fetch,
|
|
463
|
+
);
|
|
464
|
+
if (!isRecord(channel) || typeof channel.type !== "number")
|
|
465
|
+
throw new Error("Discord channel type unavailable");
|
|
466
|
+
if ([10, 11, 12].includes(channel.type)) {
|
|
467
|
+
if (typeof channel.parent_id !== "string" || !channel.parent_id)
|
|
468
|
+
throw new Error("Discord thread parent unavailable");
|
|
469
|
+
rememberThread(message.channelId, channel.parent_id, message.senderId);
|
|
470
|
+
return { ...message, threadId: message.channelId, parentChannelId: channel.parent_id };
|
|
471
|
+
}
|
|
472
|
+
if (channel.type !== 0 && channel.type !== 5) throw new Error("Discord channel cannot host a reply thread");
|
|
473
|
+
const thread = await messengerFetchJson(
|
|
474
|
+
`${API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/threads`,
|
|
475
|
+
{ method: "POST", headers: headers(), body: JSON.stringify({ name: `Request ${message.messageId}` }) },
|
|
476
|
+
signal,
|
|
477
|
+
deps.fetch,
|
|
478
|
+
);
|
|
479
|
+
if (!isRecord(thread) || typeof thread.id !== "string" || !thread.id)
|
|
480
|
+
throw new Error("Discord thread creation returned no ID");
|
|
481
|
+
rememberThread(thread.id, message.channelId, message.senderId);
|
|
482
|
+
return { ...message, threadId: thread.id };
|
|
483
|
+
},
|
|
484
|
+
async status(message, state, signal) {
|
|
485
|
+
const base = `${API}/channels/${encodeURIComponent(message.channelId)}/messages/${encodeURIComponent(message.messageId)}/reactions`;
|
|
486
|
+
const processing = encodeURIComponent("⏳");
|
|
487
|
+
if (state !== "processing") {
|
|
488
|
+
try {
|
|
489
|
+
await messengerFetchJson(
|
|
490
|
+
`${base}/${processing}/@me`,
|
|
491
|
+
{ method: "DELETE", headers: headers() },
|
|
492
|
+
signal,
|
|
493
|
+
deps.fetch,
|
|
494
|
+
);
|
|
495
|
+
} catch {
|
|
496
|
+
logger.warn("Discord processing reaction removal failed");
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
const emoji = state === "processing" ? "⏳" : state === "success" ? "✅" : "❌";
|
|
500
|
+
await messengerFetchJson(
|
|
501
|
+
`${base}/${encodeURIComponent(emoji)}/@me`,
|
|
502
|
+
{ method: "PUT", headers: headers() },
|
|
503
|
+
signal,
|
|
504
|
+
deps.fetch,
|
|
505
|
+
);
|
|
506
|
+
},
|
|
360
507
|
async send(message, text, signal) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
508
|
+
let files: { name: string; data: Uint8Array }[] = [];
|
|
509
|
+
if (config.artifactDir) {
|
|
510
|
+
try {
|
|
511
|
+
const result = await readMessengerArtifacts(config.artifactDir, message, text);
|
|
512
|
+
text = result.text;
|
|
513
|
+
files = result.files;
|
|
514
|
+
} catch {
|
|
515
|
+
await messengerFetchJson(
|
|
516
|
+
`${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
517
|
+
{
|
|
518
|
+
method: "POST",
|
|
519
|
+
headers: headers(),
|
|
520
|
+
body: JSON.stringify({
|
|
521
|
+
content: "File delivery failed: check the output path and size (maximum 8 MiB).",
|
|
522
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
523
|
+
}),
|
|
524
|
+
},
|
|
525
|
+
signal,
|
|
526
|
+
deps.fetch,
|
|
527
|
+
);
|
|
528
|
+
throw new Error("Discord artifact delivery failed");
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const { chunks, needsAttachment } = splitDiscordText(text);
|
|
532
|
+
for (let index = 0; index < chunks.length + Number(needsAttachment); index++) {
|
|
533
|
+
for (let attempt = 0; ; attempt++) {
|
|
534
|
+
try {
|
|
535
|
+
const attachment = index === chunks.length;
|
|
536
|
+
const requestHeaders = headers();
|
|
537
|
+
let body: string | FormData;
|
|
538
|
+
if (attachment) {
|
|
539
|
+
delete requestHeaders["Content-Type"];
|
|
540
|
+
const form = new FormData();
|
|
541
|
+
form.append(
|
|
542
|
+
"payload_json",
|
|
543
|
+
JSON.stringify({ allowed_mentions: { parse: [], replied_user: false } }),
|
|
544
|
+
);
|
|
545
|
+
form.append("files[0]", new Blob([text], { type: "text/plain; charset=utf-8" }), "response.txt");
|
|
546
|
+
body = form;
|
|
547
|
+
} else {
|
|
548
|
+
body = JSON.stringify({
|
|
549
|
+
content: chunks[index],
|
|
550
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
551
|
+
...(index === 0 &&
|
|
552
|
+
message.messageId &&
|
|
553
|
+
(!message.threadId || message.threadId === message.channelId)
|
|
554
|
+
? { message_reference: { message_id: message.messageId, fail_if_not_exists: false } }
|
|
555
|
+
: {}),
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
await messengerFetchJson(
|
|
559
|
+
`${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
560
|
+
{
|
|
561
|
+
method: "POST",
|
|
562
|
+
headers: requestHeaders,
|
|
563
|
+
body,
|
|
564
|
+
},
|
|
565
|
+
signal,
|
|
566
|
+
deps.fetch,
|
|
567
|
+
);
|
|
568
|
+
break;
|
|
569
|
+
} catch (error) {
|
|
570
|
+
if (error instanceof MessengerHttpError && error.status === 429 && attempt < 3 && !signal?.aborted) {
|
|
571
|
+
try {
|
|
572
|
+
await waitForReconnect(
|
|
573
|
+
error.retryAfterMs ?? 1_000,
|
|
574
|
+
signal ?? new AbortController().signal,
|
|
575
|
+
clock,
|
|
576
|
+
);
|
|
577
|
+
if (!signal?.aborted) continue;
|
|
578
|
+
} catch {
|
|
579
|
+
// Cancellation or timer failure stops delivery without replaying prior chunks.
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
logger.warn("Discord reply partially delivered", {
|
|
583
|
+
deliveredChunks: Math.min(index, chunks.length),
|
|
584
|
+
totalChunks: chunks.length,
|
|
585
|
+
...(index === chunks.length ? { attachmentFailed: true } : {}),
|
|
586
|
+
});
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
for (const file of files) {
|
|
592
|
+
const requestHeaders = headers();
|
|
593
|
+
delete requestHeaders["Content-Type"];
|
|
594
|
+
const form = new FormData();
|
|
595
|
+
form.append("payload_json", JSON.stringify({ allowed_mentions: { parse: [], replied_user: false } }));
|
|
596
|
+
form.append("files[0]", new Blob([file.data]), file.name);
|
|
597
|
+
for (let attempt = 0; ; attempt++) {
|
|
598
|
+
try {
|
|
599
|
+
await messengerFetchJson(
|
|
600
|
+
`${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
601
|
+
{ method: "POST", headers: requestHeaders, body: form },
|
|
602
|
+
signal,
|
|
603
|
+
deps.fetch,
|
|
604
|
+
);
|
|
605
|
+
break;
|
|
606
|
+
} catch (error) {
|
|
607
|
+
if (error instanceof MessengerHttpError && error.status === 429 && attempt < 3 && !signal?.aborted) {
|
|
608
|
+
await waitForReconnect(error.retryAfterMs ?? 1_000, signal ?? new AbortController().signal, clock);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (!signal?.aborted) {
|
|
612
|
+
await messengerFetchJson(
|
|
613
|
+
`${API}/channels/${encodeURIComponent(message.threadId ?? message.channelId)}/messages`,
|
|
614
|
+
{
|
|
615
|
+
method: "POST",
|
|
616
|
+
headers: headers(),
|
|
617
|
+
body: JSON.stringify({
|
|
618
|
+
content: "File upload failed; some files may not have been delivered.",
|
|
619
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
620
|
+
}),
|
|
621
|
+
},
|
|
622
|
+
signal,
|
|
623
|
+
deps.fetch,
|
|
624
|
+
).catch(() => {});
|
|
625
|
+
}
|
|
626
|
+
throw error;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
379
629
|
}
|
|
380
630
|
},
|
|
381
631
|
};
|