jeopi-cli 16.5.6 → 16.6.2
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 +21 -0
- package/README.md +100 -0
- package/dist/cli.js +3717 -3882
- package/dist/types/cli/messenger-cli.d.ts +10 -0
- package/dist/types/commands/messenger.d.ts +18 -0
- package/dist/types/config/model-registry.d.ts +3 -1
- package/dist/types/messenger/config.d.ts +4 -0
- package/dist/types/messenger/discord.d.ts +10 -0
- package/dist/types/messenger/gateway.d.ts +9 -0
- package/dist/types/messenger/http.d.ts +9 -0
- package/dist/types/messenger/session.d.ts +17 -0
- package/dist/types/messenger/slack.d.ts +2 -0
- package/dist/types/messenger/telegram.d.ts +2 -0
- package/dist/types/messenger/types.d.ts +38 -0
- package/package.json +12 -12
- package/src/cli/messenger-cli.ts +78 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands/messenger.ts +30 -0
- package/src/config/model-registry.ts +43 -8
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/messenger/config.ts +111 -0
- package/src/messenger/discord.ts +382 -0
- package/src/messenger/gateway.ts +153 -0
- package/src/messenger/http.ts +87 -0
- package/src/messenger/session.ts +219 -0
- package/src/messenger/slack.ts +333 -0
- package/src/messenger/telegram.ts +244 -0
- package/src/messenger/types.ts +47 -0
- package/src/modes/controllers/selector-controller.ts +1 -2
- package/src/modes/rpc/rpc-mode.ts +1 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +1 -2
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { MessengerConfig, MessengerPlatform, MessengerPlatformConfig } from "./types";
|
|
3
|
+
|
|
4
|
+
const PLATFORMS: MessengerPlatform[] = ["discord", "telegram", "slack"];
|
|
5
|
+
const TOOLS: Record<string, true> = { read: true, grep: true, glob: true, bash: true, edit: true, write: true };
|
|
6
|
+
|
|
7
|
+
function object(value: unknown, label: string): Record<string, unknown> {
|
|
8
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
9
|
+
throw new Error(`${label} must be an object`);
|
|
10
|
+
}
|
|
11
|
+
return value as Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function keys(value: Record<string, unknown>, allowed: string[], label: string): void {
|
|
15
|
+
if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error(`${label} contains an unknown setting`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function string(value: unknown, label: string): string {
|
|
19
|
+
if (typeof value !== "string" || value.trim() !== value || !value || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
20
|
+
throw new Error(`${label} must be a nonempty string without surrounding whitespace or control characters`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ids(value: unknown, label: string): string[] {
|
|
26
|
+
if (!Array.isArray(value) || value.length === 0) throw new Error(`${label} must be a nonempty list of exact IDs`);
|
|
27
|
+
const result = value.map(item => string(item, label));
|
|
28
|
+
if (result.some(id => /[\s*]/u.test(id)) || new Set(result).size !== result.length) {
|
|
29
|
+
throw new Error(`${label} must contain unique exact IDs, without whitespace or wildcards`);
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function envName(value: unknown, fallback: string, label: string): string {
|
|
35
|
+
const result = value === undefined ? fallback : string(value, label);
|
|
36
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(result)) throw new Error(`${label} must name an environment variable`);
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseMessengerConfig(value: unknown, baseDir: string): MessengerConfig {
|
|
41
|
+
const input = object(value, "Messenger config");
|
|
42
|
+
keys(input, ["cwd", "sessionDir", "toolNames", "platforms"], "Messenger config");
|
|
43
|
+
const platformsInput = object(input.platforms, "platforms");
|
|
44
|
+
keys(platformsInput, PLATFORMS, "platforms");
|
|
45
|
+
if (Object.keys(platformsInput).length === 0) throw new Error("At least one messenger platform is required");
|
|
46
|
+
const platforms: MessengerConfig["platforms"] = {};
|
|
47
|
+
for (const platform of PLATFORMS) {
|
|
48
|
+
if (!Object.hasOwn(platformsInput, platform)) continue;
|
|
49
|
+
const item = object(platformsInput[platform], `${platform} config`);
|
|
50
|
+
keys(
|
|
51
|
+
item,
|
|
52
|
+
platform === "slack"
|
|
53
|
+
? ["tokenEnv", "appTokenEnv", "allowedUserIds", "allowedChannelIds"]
|
|
54
|
+
: ["tokenEnv", "allowedUserIds", "allowedChannelIds"],
|
|
55
|
+
`${platform} config`,
|
|
56
|
+
);
|
|
57
|
+
const config: MessengerPlatformConfig = {
|
|
58
|
+
tokenEnv: envName(item.tokenEnv, `JEOPI_${platform.toUpperCase()}_BOT_TOKEN`, `${platform}.tokenEnv`),
|
|
59
|
+
allowedUserIds: ids(item.allowedUserIds, `${platform}.allowedUserIds`),
|
|
60
|
+
};
|
|
61
|
+
if (platform === "slack")
|
|
62
|
+
config.appTokenEnv = envName(item.appTokenEnv, "JEOPI_SLACK_APP_TOKEN", "slack.appTokenEnv");
|
|
63
|
+
if (item.allowedChannelIds !== undefined)
|
|
64
|
+
config.allowedChannelIds = ids(item.allowedChannelIds, `${platform}.allowedChannelIds`);
|
|
65
|
+
platforms[platform] = config;
|
|
66
|
+
}
|
|
67
|
+
const toolNames = input.toolNames === undefined ? [] : input.toolNames;
|
|
68
|
+
if (
|
|
69
|
+
!Array.isArray(toolNames) ||
|
|
70
|
+
toolNames.some(name => typeof name !== "string" || !Object.hasOwn(TOOLS, name)) ||
|
|
71
|
+
new Set(toolNames).size !== toolNames.length
|
|
72
|
+
) {
|
|
73
|
+
throw new Error("toolNames must be a unique list drawn from read, grep, glob, bash, edit, write");
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
cwd: path.resolve(baseDir, input.cwd === undefined ? "." : string(input.cwd, "cwd")),
|
|
77
|
+
sessionDir: path.resolve(
|
|
78
|
+
baseDir,
|
|
79
|
+
input.sessionDir === undefined ? ".jeopi/messenger-sessions" : string(input.sessionDir, "sessionDir"),
|
|
80
|
+
),
|
|
81
|
+
toolNames: [...toolNames] as string[],
|
|
82
|
+
platforms,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function loadMessengerConfig(file: string): Promise<MessengerConfig> {
|
|
87
|
+
let value: unknown;
|
|
88
|
+
try {
|
|
89
|
+
value = await Bun.file(file).json();
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error("Cannot read messenger configuration as JSON");
|
|
92
|
+
}
|
|
93
|
+
return parseMessengerConfig(value, path.dirname(path.resolve(file)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function validateMessengerEnvironment(
|
|
97
|
+
config: MessengerConfig,
|
|
98
|
+
env: Record<string, string | undefined> = process.env,
|
|
99
|
+
): void {
|
|
100
|
+
for (const platform of PLATFORMS) {
|
|
101
|
+
const item = config.platforms[platform];
|
|
102
|
+
if (!item) continue;
|
|
103
|
+
for (const name of [item.tokenEnv, item.appTokenEnv]) {
|
|
104
|
+
if (name === undefined) continue;
|
|
105
|
+
const token = env[name];
|
|
106
|
+
if (!token?.trim() || token !== token.trim() || /[\s\u0000-\u001f\u007f]/u.test(token)) {
|
|
107
|
+
throw new Error(`${platform} requires a nonempty credential in ${name}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { isRecord, logger } from "jeopi-utils";
|
|
2
|
+
import { MessengerHttpError, messengerFetchJson, splitMessengerText } from "./http";
|
|
3
|
+
import type { MessengerAdapter, MessengerMessage, MessengerPlatformConfig, MessengerTransportDeps } from "./types";
|
|
4
|
+
|
|
5
|
+
// Protocol: https://docs.discord.com/developers/events/gateway
|
|
6
|
+
// Close codes: https://docs.discord.com/developers/topics/opcodes-and-status-codes
|
|
7
|
+
// REST messages: https://docs.discord.com/developers/resources/message#create-message
|
|
8
|
+
const API = "https://discord.com/api/v10";
|
|
9
|
+
const INTENTS = (1 << 9) | (1 << 12) | (1 << 15);
|
|
10
|
+
const PERMANENT_CLOSE_CODES = new Set([4004, 4010, 4011, 4012, 4013, 4014]);
|
|
11
|
+
|
|
12
|
+
class DiscordConfigurationError extends Error {}
|
|
13
|
+
|
|
14
|
+
export interface DiscordClock {
|
|
15
|
+
/** Schedule once and return an idempotent cancellation function. */
|
|
16
|
+
schedule(callback: () => void, delayMs: number): () => void;
|
|
17
|
+
random(): number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DiscordTransportDeps extends MessengerTransportDeps {
|
|
21
|
+
clock?: DiscordClock;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const defaultClock: DiscordClock = {
|
|
25
|
+
schedule(callback, delayMs) {
|
|
26
|
+
const timer = setTimeout(callback, delayMs);
|
|
27
|
+
return () => clearTimeout(timer);
|
|
28
|
+
},
|
|
29
|
+
random: Math.random,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function waitForReconnect(delayMs: number, signal: AbortSignal, clock: DiscordClock): Promise<void> {
|
|
33
|
+
if (signal.aborted) return Promise.resolve();
|
|
34
|
+
const result = Promise.withResolvers<void>();
|
|
35
|
+
const finish = (): void => {
|
|
36
|
+
cancel();
|
|
37
|
+
signal.removeEventListener("abort", finish);
|
|
38
|
+
result.resolve();
|
|
39
|
+
};
|
|
40
|
+
const cancel = clock.schedule(finish, delayMs);
|
|
41
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
42
|
+
if (signal.aborted) finish();
|
|
43
|
+
return result.promise;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface DiscordSession {
|
|
47
|
+
id?: string;
|
|
48
|
+
url?: string;
|
|
49
|
+
sequence: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function gatewayUrl(value: unknown): string {
|
|
53
|
+
let url: URL;
|
|
54
|
+
try {
|
|
55
|
+
if (typeof value !== "string") throw new Error();
|
|
56
|
+
url = new URL(value);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new DiscordConfigurationError("Discord returned an invalid Gateway URL");
|
|
59
|
+
}
|
|
60
|
+
if (
|
|
61
|
+
url.protocol !== "wss:" ||
|
|
62
|
+
!/^gateway(?:-[a-z0-9-]+)?\.discord\.gg$/.test(url.hostname) ||
|
|
63
|
+
url.username ||
|
|
64
|
+
url.password ||
|
|
65
|
+
url.port ||
|
|
66
|
+
url.hash ||
|
|
67
|
+
url.pathname !== "/"
|
|
68
|
+
) {
|
|
69
|
+
throw new DiscordConfigurationError("Discord returned an untrusted Gateway URL");
|
|
70
|
+
}
|
|
71
|
+
url.search = "?v=10&encoding=json";
|
|
72
|
+
return url.href;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clearSession(session: DiscordSession): void {
|
|
76
|
+
session.id = undefined;
|
|
77
|
+
session.url = undefined;
|
|
78
|
+
session.sequence = null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeMessage(value: unknown, accountId: string): MessengerMessage | undefined {
|
|
82
|
+
if (!isRecord(value) || !isRecord(value.author)) return;
|
|
83
|
+
if (value.author.bot || value.webhook_id || value.author.id === accountId) return;
|
|
84
|
+
if (
|
|
85
|
+
typeof value.id !== "string" ||
|
|
86
|
+
!value.id ||
|
|
87
|
+
typeof value.channel_id !== "string" ||
|
|
88
|
+
!value.channel_id ||
|
|
89
|
+
typeof value.author.id !== "string" ||
|
|
90
|
+
!value.author.id ||
|
|
91
|
+
typeof value.content !== "string" ||
|
|
92
|
+
!value.content.trim() ||
|
|
93
|
+
(value.type !== undefined && value.type !== 0 && value.type !== 19)
|
|
94
|
+
)
|
|
95
|
+
return;
|
|
96
|
+
const mentioned =
|
|
97
|
+
Array.isArray(value.mentions) && value.mentions.some(user => isRecord(user) && user.id === accountId);
|
|
98
|
+
const text = value.content.replaceAll(`<@${accountId}>`, "").replaceAll(`<@!${accountId}>`, "").trim();
|
|
99
|
+
if (!text) return;
|
|
100
|
+
return {
|
|
101
|
+
platform: "discord",
|
|
102
|
+
accountId,
|
|
103
|
+
channelId: value.channel_id,
|
|
104
|
+
senderId: value.author.id,
|
|
105
|
+
messageId: value.id,
|
|
106
|
+
text,
|
|
107
|
+
direct: value.guild_id === undefined,
|
|
108
|
+
mentioned,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function connectGateway(
|
|
113
|
+
url: string,
|
|
114
|
+
token: string,
|
|
115
|
+
accountId: string,
|
|
116
|
+
session: DiscordSession,
|
|
117
|
+
onMessage: (message: MessengerMessage) => Promise<void>,
|
|
118
|
+
signal: AbortSignal,
|
|
119
|
+
WebSocketImpl: typeof WebSocket,
|
|
120
|
+
clock: DiscordClock,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
if (signal.aborted) return Promise.resolve();
|
|
123
|
+
const result = Promise.withResolvers<void>();
|
|
124
|
+
let socket: WebSocket;
|
|
125
|
+
try {
|
|
126
|
+
socket = new WebSocketImpl(url);
|
|
127
|
+
} catch {
|
|
128
|
+
clearSession(session);
|
|
129
|
+
return Promise.resolve();
|
|
130
|
+
}
|
|
131
|
+
let finished = false;
|
|
132
|
+
let hello = false;
|
|
133
|
+
let established = false;
|
|
134
|
+
let acknowledged = true;
|
|
135
|
+
let cancelHeartbeat: (() => void) | undefined;
|
|
136
|
+
let cancelHandshake: (() => void) | undefined;
|
|
137
|
+
|
|
138
|
+
const finish = (error?: Error): void => {
|
|
139
|
+
if (finished) return;
|
|
140
|
+
finished = true;
|
|
141
|
+
// A failed resume handshake must fall back to the initial Gateway.
|
|
142
|
+
if (!established) clearSession(session);
|
|
143
|
+
cancelHeartbeat?.();
|
|
144
|
+
cancelHandshake?.();
|
|
145
|
+
signal.removeEventListener("abort", abort);
|
|
146
|
+
socket.removeEventListener("message", receive);
|
|
147
|
+
socket.removeEventListener("close", close);
|
|
148
|
+
socket.removeEventListener("error", failed);
|
|
149
|
+
try {
|
|
150
|
+
// Normal shutdown invalidates the session; reconnects must preserve it.
|
|
151
|
+
socket.close(signal.aborted ? 1000 : 4000);
|
|
152
|
+
} catch {
|
|
153
|
+
// A failed handshake can already have closed the underlying socket.
|
|
154
|
+
}
|
|
155
|
+
if (error) result.reject(error);
|
|
156
|
+
else result.resolve();
|
|
157
|
+
};
|
|
158
|
+
const abort = (): void => finish();
|
|
159
|
+
const failed = (): void => finish();
|
|
160
|
+
const send = (op: number, d: unknown): void => {
|
|
161
|
+
if (finished) return;
|
|
162
|
+
try {
|
|
163
|
+
socket.send(JSON.stringify({ op, d }));
|
|
164
|
+
} catch {
|
|
165
|
+
finish();
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const heartbeat = (): void => {
|
|
169
|
+
acknowledged = false;
|
|
170
|
+
send(1, session.sequence);
|
|
171
|
+
};
|
|
172
|
+
const close = (event: CloseEvent): void => {
|
|
173
|
+
if (PERMANENT_CLOSE_CODES.has(event.code)) {
|
|
174
|
+
const reason =
|
|
175
|
+
event.code === 4004
|
|
176
|
+
? "authentication failed"
|
|
177
|
+
: event.code === 4013 || event.code === 4014
|
|
178
|
+
? "message intents are invalid or not enabled"
|
|
179
|
+
: "Gateway configuration is unsupported";
|
|
180
|
+
finish(new DiscordConfigurationError(`Discord ${reason} (Gateway ${event.code})`));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if ([1000, 1001, 4003, 4007, 4009].includes(event.code)) clearSession(session);
|
|
184
|
+
finish();
|
|
185
|
+
};
|
|
186
|
+
const receive = (event: MessageEvent): void => {
|
|
187
|
+
if (finished || signal.aborted) return;
|
|
188
|
+
let packet: unknown;
|
|
189
|
+
try {
|
|
190
|
+
if (typeof event.data !== "string") return;
|
|
191
|
+
packet = JSON.parse(event.data);
|
|
192
|
+
} catch {
|
|
193
|
+
finish();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (!isRecord(packet)) return;
|
|
197
|
+
if (packet.op === 10) {
|
|
198
|
+
if (hello) return;
|
|
199
|
+
if (
|
|
200
|
+
!isRecord(packet.d) ||
|
|
201
|
+
typeof packet.d.heartbeat_interval !== "number" ||
|
|
202
|
+
!Number.isFinite(packet.d.heartbeat_interval) ||
|
|
203
|
+
packet.d.heartbeat_interval <= 0 ||
|
|
204
|
+
packet.d.heartbeat_interval > 300_000
|
|
205
|
+
) {
|
|
206
|
+
finish(new DiscordConfigurationError("Discord returned an invalid heartbeat interval"));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
hello = true;
|
|
210
|
+
const interval = packet.d.heartbeat_interval;
|
|
211
|
+
const tick = (): void => {
|
|
212
|
+
if (finished) return;
|
|
213
|
+
if (!acknowledged) {
|
|
214
|
+
finish();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
heartbeat();
|
|
218
|
+
if (!finished) cancelHeartbeat = clock.schedule(tick, interval);
|
|
219
|
+
};
|
|
220
|
+
cancelHeartbeat = clock.schedule(tick, clock.random() * interval);
|
|
221
|
+
if (session.id && session.sequence !== null) {
|
|
222
|
+
send(6, { token, session_id: session.id, seq: session.sequence });
|
|
223
|
+
} else {
|
|
224
|
+
send(2, {
|
|
225
|
+
token,
|
|
226
|
+
intents: INTENTS,
|
|
227
|
+
properties: { os: process.platform, browser: "jeopi", device: "jeopi" },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
} else if (packet.op === 11) {
|
|
231
|
+
acknowledged = true;
|
|
232
|
+
} else if (packet.op === 1) {
|
|
233
|
+
// Unsolicited requests must not shorten the scheduled ACK deadline.
|
|
234
|
+
send(1, session.sequence);
|
|
235
|
+
} else if (packet.op === 7 || packet.op === 9) {
|
|
236
|
+
if (packet.op === 9 && packet.d !== true) clearSession(session);
|
|
237
|
+
finish();
|
|
238
|
+
} else if (packet.op === 0) {
|
|
239
|
+
if (typeof packet.s === "number" && Number.isSafeInteger(packet.s)) session.sequence = packet.s;
|
|
240
|
+
if (packet.t === "READY") {
|
|
241
|
+
if (!isRecord(packet.d) || typeof packet.d.session_id !== "string" || !packet.d.session_id) {
|
|
242
|
+
finish(new DiscordConfigurationError("Discord returned an invalid Gateway session"));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
session.url = gatewayUrl(packet.d.resume_gateway_url);
|
|
247
|
+
} catch {
|
|
248
|
+
finish(new DiscordConfigurationError("Discord returned an untrusted resume Gateway URL"));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
session.id = packet.d.session_id;
|
|
252
|
+
established = true;
|
|
253
|
+
cancelHandshake?.();
|
|
254
|
+
} else if (packet.t === "RESUMED") {
|
|
255
|
+
established = true;
|
|
256
|
+
cancelHandshake?.();
|
|
257
|
+
} else if (packet.t === "MESSAGE_CREATE") {
|
|
258
|
+
const message = normalizeMessage(packet.d, accountId);
|
|
259
|
+
if (message) {
|
|
260
|
+
// The gateway owns authorization, deduplication and its bounded work queue.
|
|
261
|
+
void Promise.resolve()
|
|
262
|
+
.then(() => {
|
|
263
|
+
if (!signal.aborted) return onMessage(message);
|
|
264
|
+
})
|
|
265
|
+
.catch(() => logger.warn("Discord message handler failed"));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
socket.addEventListener("message", receive);
|
|
271
|
+
socket.addEventListener("close", close);
|
|
272
|
+
socket.addEventListener("error", failed);
|
|
273
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
274
|
+
cancelHandshake = clock.schedule(() => finish(), 35_000);
|
|
275
|
+
if (signal.aborted) finish();
|
|
276
|
+
return result.promise;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function createDiscordAdapter(
|
|
280
|
+
config: MessengerPlatformConfig,
|
|
281
|
+
env: Record<string, string | undefined> = process.env,
|
|
282
|
+
deps: DiscordTransportDeps = {},
|
|
283
|
+
): MessengerAdapter {
|
|
284
|
+
const token = env[config.tokenEnv]?.trim();
|
|
285
|
+
const clock = deps.clock ?? defaultClock;
|
|
286
|
+
const headers = (): Record<string, string> => {
|
|
287
|
+
if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
|
|
288
|
+
return { Authorization: `Bot ${token}`, "Content-Type": "application/json" };
|
|
289
|
+
};
|
|
290
|
+
return {
|
|
291
|
+
platform: "discord",
|
|
292
|
+
async start(onMessage, signal) {
|
|
293
|
+
const session: DiscordSession = { sequence: null };
|
|
294
|
+
let accountId: string | undefined;
|
|
295
|
+
let initialUrl: string | undefined;
|
|
296
|
+
let delay = 5_000;
|
|
297
|
+
while (!signal.aborted) {
|
|
298
|
+
let retryDelay = delay;
|
|
299
|
+
try {
|
|
300
|
+
if (!accountId) {
|
|
301
|
+
const user = await messengerFetchJson(`${API}/users/@me`, { headers: headers() }, signal, deps.fetch);
|
|
302
|
+
if (signal.aborted) return;
|
|
303
|
+
if (!isRecord(user) || typeof user.id !== "string" || !user.id || user.bot !== true) {
|
|
304
|
+
throw new DiscordConfigurationError("Discord credentials must identify a bot account");
|
|
305
|
+
}
|
|
306
|
+
accountId = user.id;
|
|
307
|
+
}
|
|
308
|
+
if (!initialUrl) {
|
|
309
|
+
const gateway = await messengerFetchJson(
|
|
310
|
+
`${API}/gateway/bot`,
|
|
311
|
+
{ headers: headers() },
|
|
312
|
+
signal,
|
|
313
|
+
deps.fetch,
|
|
314
|
+
);
|
|
315
|
+
if (!isRecord(gateway))
|
|
316
|
+
throw new DiscordConfigurationError("Discord returned invalid Gateway discovery");
|
|
317
|
+
if (isRecord(gateway.session_start_limit) && gateway.session_start_limit.remaining === 0) {
|
|
318
|
+
throw new DiscordConfigurationError("Discord Gateway session start limit is exhausted");
|
|
319
|
+
}
|
|
320
|
+
initialUrl = gatewayUrl(gateway.url);
|
|
321
|
+
}
|
|
322
|
+
await connectGateway(
|
|
323
|
+
session.url ?? initialUrl,
|
|
324
|
+
token!,
|
|
325
|
+
accountId,
|
|
326
|
+
session,
|
|
327
|
+
onMessage,
|
|
328
|
+
signal,
|
|
329
|
+
deps.WebSocket ?? WebSocket,
|
|
330
|
+
clock,
|
|
331
|
+
);
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (signal.aborted) return;
|
|
334
|
+
if (error instanceof DiscordConfigurationError) throw error;
|
|
335
|
+
if (
|
|
336
|
+
error instanceof MessengerHttpError &&
|
|
337
|
+
error.status >= 400 &&
|
|
338
|
+
error.status < 500 &&
|
|
339
|
+
error.status !== 429
|
|
340
|
+
) {
|
|
341
|
+
throw new DiscordConfigurationError(
|
|
342
|
+
`Discord authentication or configuration failed (HTTP ${error.status})`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (error instanceof MessengerHttpError && error.retryAfterMs !== undefined) {
|
|
346
|
+
retryDelay = Math.max(retryDelay, error.retryAfterMs);
|
|
347
|
+
}
|
|
348
|
+
logger.warn("Discord connection failed; retrying");
|
|
349
|
+
}
|
|
350
|
+
if (signal.aborted) return;
|
|
351
|
+
try {
|
|
352
|
+
await waitForReconnect(retryDelay, signal, clock);
|
|
353
|
+
} catch {
|
|
354
|
+
if (signal.aborted) return;
|
|
355
|
+
throw new Error("Discord reconnect wait failed");
|
|
356
|
+
}
|
|
357
|
+
delay = Math.min(delay * 2, 30_000);
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
async send(message, text, signal) {
|
|
361
|
+
const chunks = splitMessengerText(text, 2_000);
|
|
362
|
+
for (let index = 0; index < chunks.length; index++) {
|
|
363
|
+
await messengerFetchJson(
|
|
364
|
+
`${API}/channels/${encodeURIComponent(message.channelId)}/messages`,
|
|
365
|
+
{
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: headers(),
|
|
368
|
+
body: JSON.stringify({
|
|
369
|
+
content: chunks[index],
|
|
370
|
+
allowed_mentions: { parse: [], replied_user: false },
|
|
371
|
+
...(index === 0
|
|
372
|
+
? { message_reference: { message_id: message.messageId, fail_if_not_exists: false } }
|
|
373
|
+
: {}),
|
|
374
|
+
}),
|
|
375
|
+
},
|
|
376
|
+
signal,
|
|
377
|
+
deps.fetch,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { logger } from "jeopi-utils";
|
|
2
|
+
import type { MessengerAdapter, MessengerConfig, MessengerMessage, MessengerReply } from "./types";
|
|
3
|
+
import { MessengerFatalError } from "./types";
|
|
4
|
+
|
|
5
|
+
const MAX_MESSAGE_LENGTH = 32_000;
|
|
6
|
+
const MAX_QUEUED_MESSAGES = 100;
|
|
7
|
+
const MAX_SEEN_MESSAGES = 10_000;
|
|
8
|
+
|
|
9
|
+
/** Include the sender even in shared threads: remote histories must not cross users. */
|
|
10
|
+
export function messengerConversationKey(message: MessengerMessage): string {
|
|
11
|
+
return JSON.stringify([
|
|
12
|
+
message.platform,
|
|
13
|
+
message.accountId,
|
|
14
|
+
message.channelId,
|
|
15
|
+
message.threadId ?? "",
|
|
16
|
+
message.senderId,
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isMessengerMessageAllowed(message: MessengerMessage, config: MessengerConfig): boolean {
|
|
21
|
+
const platform = config.platforms[message.platform];
|
|
22
|
+
return Boolean(
|
|
23
|
+
platform &&
|
|
24
|
+
message.accountId &&
|
|
25
|
+
message.channelId &&
|
|
26
|
+
message.senderId &&
|
|
27
|
+
message.messageId &&
|
|
28
|
+
message.text.trim() &&
|
|
29
|
+
message.text.length <= MAX_MESSAGE_LENGTH &&
|
|
30
|
+
platform.allowedUserIds.includes(message.senderId) &&
|
|
31
|
+
(!platform.allowedChannelIds || platform.allowedChannelIds.includes(message.channelId)) &&
|
|
32
|
+
(message.direct || message.mentioned),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PendingMessage {
|
|
37
|
+
message: MessengerMessage;
|
|
38
|
+
adapter: MessengerAdapter;
|
|
39
|
+
signal: AbortSignal;
|
|
40
|
+
resolve: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MessengerGateway {
|
|
44
|
+
run(signal: AbortSignal): Promise<void>;
|
|
45
|
+
handle(message: MessengerMessage, signal?: AbortSignal): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createMessengerGateway(
|
|
49
|
+
config: MessengerConfig,
|
|
50
|
+
adapters: MessengerAdapter[],
|
|
51
|
+
reply: MessengerReply,
|
|
52
|
+
): MessengerGateway {
|
|
53
|
+
const byPlatform = new Map(adapters.map(adapter => [adapter.platform, adapter]));
|
|
54
|
+
if (byPlatform.size !== adapters.length) throw new Error("Duplicate messenger adapter");
|
|
55
|
+
for (const platform of Object.keys(config.platforms)) {
|
|
56
|
+
if (!adapters.some(adapter => adapter.platform === platform))
|
|
57
|
+
throw new Error("Missing configured messenger adapter");
|
|
58
|
+
}
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
const queue: PendingMessage[] = [];
|
|
61
|
+
const lifetime = new AbortController();
|
|
62
|
+
let draining: Promise<void> | undefined;
|
|
63
|
+
let workerActive = false;
|
|
64
|
+
let running = false;
|
|
65
|
+
let fatalError: MessengerFatalError | undefined;
|
|
66
|
+
|
|
67
|
+
async function drain(): Promise<void> {
|
|
68
|
+
while (queue.length > 0) {
|
|
69
|
+
const pending = queue.shift();
|
|
70
|
+
if (!pending) break;
|
|
71
|
+
const { message, adapter, signal, resolve } = pending;
|
|
72
|
+
try {
|
|
73
|
+
if (signal.aborted) continue;
|
|
74
|
+
let text: string;
|
|
75
|
+
try {
|
|
76
|
+
text = await reply(message, signal);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof MessengerFatalError) {
|
|
79
|
+
fatalError = error;
|
|
80
|
+
lifetime.abort();
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (signal.aborted) continue;
|
|
84
|
+
logger.warn("Messenger agent turn failed", { platform: message.platform });
|
|
85
|
+
text = "The request could not be completed. Please try again.";
|
|
86
|
+
}
|
|
87
|
+
if (!signal.aborted && text.trim()) {
|
|
88
|
+
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
89
|
+
await adapter.send(message, text, signal);
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
if (!signal.aborted) logger.warn("Messenger reply delivery failed", { platform: message.platform });
|
|
93
|
+
} finally {
|
|
94
|
+
resolve();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
workerActive = false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handle(message: MessengerMessage, signal?: AbortSignal): Promise<void> {
|
|
101
|
+
const adapter = byPlatform.get(message.platform);
|
|
102
|
+
const effectiveSignal = signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal;
|
|
103
|
+
if (effectiveSignal.aborted || !adapter || !isMessengerMessageAllowed(message, config)) return Promise.resolve();
|
|
104
|
+
const id = JSON.stringify([message.platform, message.accountId, message.channelId, message.messageId]);
|
|
105
|
+
if (seen.has(id) || queue.length >= MAX_QUEUED_MESSAGES) return Promise.resolve();
|
|
106
|
+
seen.add(id);
|
|
107
|
+
if (seen.size > MAX_SEEN_MESSAGES) {
|
|
108
|
+
const oldest = seen.values().next().value;
|
|
109
|
+
if (oldest !== undefined) seen.delete(oldest);
|
|
110
|
+
}
|
|
111
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
112
|
+
queue.push({ message, adapter, signal: effectiveSignal, resolve });
|
|
113
|
+
if (!workerActive) {
|
|
114
|
+
workerActive = true;
|
|
115
|
+
draining = drain();
|
|
116
|
+
}
|
|
117
|
+
return promise;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
handle,
|
|
122
|
+
async run(signal) {
|
|
123
|
+
if (running || lifetime.signal.aborted) throw new Error("Messenger gateway cannot be restarted");
|
|
124
|
+
running = true;
|
|
125
|
+
const abort = (): void => lifetime.abort();
|
|
126
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
127
|
+
if (signal.aborted) abort();
|
|
128
|
+
const starts: Promise<void>[] = [];
|
|
129
|
+
try {
|
|
130
|
+
if (lifetime.signal.aborted) return;
|
|
131
|
+
for (const adapter of adapters) {
|
|
132
|
+
starts.push(
|
|
133
|
+
Promise.resolve().then(async () => {
|
|
134
|
+
await adapter.start(message => handle(message), lifetime.signal);
|
|
135
|
+
if (!lifetime.signal.aborted) throw new Error("Messenger transport stopped unexpectedly");
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
await Promise.all(starts);
|
|
140
|
+
if (fatalError) throw fatalError;
|
|
141
|
+
} catch {
|
|
142
|
+
if (fatalError) throw fatalError;
|
|
143
|
+
if (!signal.aborted)
|
|
144
|
+
throw new Error("Messenger connection failed; check provider credentials, permissions and connectivity");
|
|
145
|
+
} finally {
|
|
146
|
+
lifetime.abort();
|
|
147
|
+
await Promise.allSettled(starts);
|
|
148
|
+
await draining;
|
|
149
|
+
signal.removeEventListener("abort", abort);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|