jeo-code 0.12.7 → 0.12.9
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 +7 -1
- package/README.ja.md +3 -1
- package/README.ko.md +3 -1
- package/README.md +5 -3
- package/README.zh.md +3 -1
- package/package.json +2 -1
- package/src/agent/compaction.ts +6 -7
- package/src/agent/notify/discord-api.ts +125 -28
- package/src/agent/notify/discord-artifacts.ts +126 -0
- package/src/agent/notify/discord-attachments.ts +164 -0
- package/src/agent/notify/discord-daemon.ts +213 -39
- package/src/agent/notify/session-endpoint.ts +127 -5
- package/src/agent/session.ts +7 -5
- package/src/cli/runner.ts +9 -0
- package/src/cli.ts +4 -0
- package/src/commands/launch/agy-turn.ts +20 -7
- package/src/commands/launch/confirm.ts +78 -0
- package/src/commands/launch/flags.ts +7 -0
- package/src/commands/launch/input.ts +18 -1
- package/src/commands/launch.ts +332 -41
- package/src/commands/messenger.ts +153 -0
- package/src/commands/notify.ts +3 -0
- package/src/messenger/activity.ts +30 -0
- package/src/messenger/artifacts.ts +167 -0
- package/src/messenger/config.ts +151 -0
- package/src/messenger/discord-setup.ts +568 -0
- package/src/messenger/discord.ts +707 -0
- package/src/messenger/gateway.ts +444 -0
- package/src/messenger/http.ts +88 -0
- package/src/messenger/model-policy.ts +8 -0
- package/src/messenger/service.ts +122 -0
- package/src/messenger/session-pool.ts +109 -0
- package/src/messenger/slack.ts +333 -0
- package/src/messenger/telegram.ts +241 -0
- package/src/messenger/types.ts +85 -0
- package/src/tui/components/slash.ts +1 -1
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { loadMessengerConfig, resolveMessengerToken, validateMessengerEnvironment } from "./config";
|
|
4
|
+
import { DISCORD_API, DISCORD_GATEWAY_INTENTS, discordGatewayUrl } from "./discord";
|
|
5
|
+
import { MessengerHttpError, messengerFetchJson } from "./http";
|
|
6
|
+
import { installMessengerService, messengerServicePlan } from "./service";
|
|
7
|
+
import { isEnoent, isRecord } from "./types";
|
|
8
|
+
|
|
9
|
+
export const DISCORD_TOKEN_ENV = "JEO_DISCORD_BOT_TOKEN";
|
|
10
|
+
const TOKEN_FILE = "discord-token";
|
|
11
|
+
const RUN_SCRIPT = "run-discord.sh";
|
|
12
|
+
const MESSAGE_CONTENT = 1 << 18;
|
|
13
|
+
const MESSAGE_CONTENT_LIMITED = 1 << 19;
|
|
14
|
+
/** The only application flags a bot token may change: the limited presence, member, and message intents. */
|
|
15
|
+
const EDITABLE_INTENT_FLAGS = (1 << 13) | (1 << 15) | MESSAGE_CONTENT_LIMITED;
|
|
16
|
+
const ADMINISTRATOR = 1n << 3n;
|
|
17
|
+
const EVERYTHING = (1n << 53n) - 1n;
|
|
18
|
+
/** What the gateway needs in a request channel: read, reply, react, attach, and open request threads. */
|
|
19
|
+
export const DISCORD_REQUIRED_PERMISSIONS: ReadonlyArray<readonly [name: string, bit: bigint]> = [
|
|
20
|
+
["View Channels", 1n << 10n],
|
|
21
|
+
["Send Messages", 1n << 11n],
|
|
22
|
+
["Read Message History", 1n << 16n],
|
|
23
|
+
["Add Reactions", 1n << 6n],
|
|
24
|
+
["Attach Files", 1n << 15n],
|
|
25
|
+
["Create Public Threads", 1n << 35n],
|
|
26
|
+
["Send Messages in Threads", 1n << 38n],
|
|
27
|
+
];
|
|
28
|
+
const REQUIRED_MASK = DISCORD_REQUIRED_PERMISSIONS.reduce((mask, [, bit]) => mask | bit, 0n);
|
|
29
|
+
const REQUEST_CHANNEL_TYPES: Record<number, true> = { 0: true, 5: true };
|
|
30
|
+
const THREAD_TYPES: Record<number, true> = { 10: true, 11: true, 12: true };
|
|
31
|
+
const INVITE_POLL_MS = 3_000;
|
|
32
|
+
const GATEWAY_PROBE_MS = 20_000;
|
|
33
|
+
const DM_TEST_MESSAGE = "jeo messenger is set up for this bot. Reply here to test the connection.";
|
|
34
|
+
|
|
35
|
+
export interface DiscordSetupIO {
|
|
36
|
+
readonly interactive: boolean;
|
|
37
|
+
say(line: string): void;
|
|
38
|
+
ask(question: string): Promise<string>;
|
|
39
|
+
askSecret(question: string): Promise<string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DiscordSetupOptions {
|
|
43
|
+
/** messenger.json to create or update; its directory becomes the private profile directory. */
|
|
44
|
+
configPath: string;
|
|
45
|
+
channelIds?: string[];
|
|
46
|
+
userIds?: string[];
|
|
47
|
+
toolNames?: string[];
|
|
48
|
+
artifacts?: boolean;
|
|
49
|
+
service?: boolean;
|
|
50
|
+
/** Open the owner's DM channel, allow it, and send one test message. Default true. */
|
|
51
|
+
dmTest?: boolean;
|
|
52
|
+
inviteWaitMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DiscordSetupDeps {
|
|
56
|
+
fetch?: typeof fetch;
|
|
57
|
+
WebSocket?: typeof WebSocket;
|
|
58
|
+
env?: Record<string, string | undefined>;
|
|
59
|
+
sleep?: (ms: number) => Promise<void>;
|
|
60
|
+
/** argv that starts this jeo build; defaults to the running runtime and entry. */
|
|
61
|
+
jeoCommand?: string[];
|
|
62
|
+
installService?: typeof installMessengerService;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface DiscordSetupResult {
|
|
66
|
+
configPath: string;
|
|
67
|
+
botName: string;
|
|
68
|
+
applicationId: string;
|
|
69
|
+
inviteUrl: string;
|
|
70
|
+
allowedUserIds: string[];
|
|
71
|
+
allowedChannelIds: string[];
|
|
72
|
+
runScript: string;
|
|
73
|
+
service?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DiscordBotInfo {
|
|
77
|
+
id: string;
|
|
78
|
+
username: string;
|
|
79
|
+
applicationId: string;
|
|
80
|
+
flags: number;
|
|
81
|
+
ownerIds: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface RequestChannel {
|
|
85
|
+
id: string;
|
|
86
|
+
name: string;
|
|
87
|
+
guildName: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type DiscordApiFn = (method: string, route: string, body?: unknown) => Promise<unknown>;
|
|
91
|
+
|
|
92
|
+
function discordApi(token: string, fetchImpl: typeof fetch): DiscordApiFn {
|
|
93
|
+
return (method, route, body) =>
|
|
94
|
+
messengerFetchJson(
|
|
95
|
+
`${DISCORD_API}${route}`,
|
|
96
|
+
{
|
|
97
|
+
method,
|
|
98
|
+
headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
|
|
99
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
100
|
+
},
|
|
101
|
+
undefined,
|
|
102
|
+
fetchImpl,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function str(value: unknown): string | undefined {
|
|
107
|
+
return typeof value === "string" && value ? value : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function records(value: unknown): Record<string, unknown>[] {
|
|
111
|
+
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function discordInviteUrl(applicationId: string): string {
|
|
115
|
+
const query = new URLSearchParams({ client_id: applicationId, scope: "bot", permissions: String(REQUIRED_MASK) });
|
|
116
|
+
return `https://discord.com/oauth2/authorize?${query}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function resolveToken(
|
|
120
|
+
env: Record<string, string | undefined>,
|
|
121
|
+
tokenPath: string,
|
|
122
|
+
io: DiscordSetupIO,
|
|
123
|
+
): Promise<string> {
|
|
124
|
+
let token = resolveMessengerToken(DISCORD_TOKEN_ENV, env)?.trim();
|
|
125
|
+
if (!token) {
|
|
126
|
+
try {
|
|
127
|
+
token = (await Bun.file(tokenPath).text()).trim();
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (!isEnoent(error)) throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!token && io.interactive) {
|
|
133
|
+
io.say("Developer Portal > your application > Bot > Reset Token, then paste it here (input is hidden).");
|
|
134
|
+
token = (await io.askSecret("Discord bot token: ")).trim();
|
|
135
|
+
}
|
|
136
|
+
if (!token) throw new Error(`Set ${DISCORD_TOKEN_ENV} or run this command in a terminal to enter the bot token`);
|
|
137
|
+
if (/\s/u.test(token)) throw new Error("The Discord bot token must not contain whitespace");
|
|
138
|
+
return token;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function inspectDiscordBot(api: DiscordApiFn): Promise<DiscordBotInfo> {
|
|
142
|
+
let user: unknown;
|
|
143
|
+
let app: unknown;
|
|
144
|
+
try {
|
|
145
|
+
user = await api("GET", "/users/@me");
|
|
146
|
+
app = await api("GET", "/applications/@me");
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof MessengerHttpError && error.status === 401) {
|
|
149
|
+
throw new Error("Discord rejected the bot token; copy a fresh one from Bot > Reset Token");
|
|
150
|
+
}
|
|
151
|
+
throw new Error("Could not reach Discord to verify the bot token");
|
|
152
|
+
}
|
|
153
|
+
if (!isRecord(user) || user.bot !== true || !str(user.id)) {
|
|
154
|
+
throw new Error("The token does not belong to a bot user; copy it from the Bot page, not OAuth2");
|
|
155
|
+
}
|
|
156
|
+
if (!isRecord(app) || !str(app.id)) throw new Error("Discord returned no application for this bot");
|
|
157
|
+
const team = isRecord(app.team) ? app.team : undefined;
|
|
158
|
+
const ownerIds = team
|
|
159
|
+
? [
|
|
160
|
+
str(team.owner_user_id),
|
|
161
|
+
...records(team.members)
|
|
162
|
+
.filter(member => member.membership_state === 2)
|
|
163
|
+
.map(member => (isRecord(member.user) ? str(member.user.id) : undefined)),
|
|
164
|
+
]
|
|
165
|
+
: [isRecord(app.owner) ? str(app.owner.id) : undefined];
|
|
166
|
+
return {
|
|
167
|
+
id: user.id as string,
|
|
168
|
+
username: str(user.username) ?? "bot",
|
|
169
|
+
applicationId: app.id as string,
|
|
170
|
+
flags: typeof app.flags === "number" ? app.flags : 0,
|
|
171
|
+
ownerIds: [...new Set(ownerIds.filter((id): id is string => Boolean(id)))],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function ensureMessageContentIntent(
|
|
176
|
+
api: DiscordApiFn,
|
|
177
|
+
bot: DiscordBotInfo,
|
|
178
|
+
io: DiscordSetupIO,
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
if (bot.flags & (MESSAGE_CONTENT | MESSAGE_CONTENT_LIMITED)) return;
|
|
181
|
+
let flags = 0;
|
|
182
|
+
try {
|
|
183
|
+
const updated = await api("PATCH", "/applications/@me", {
|
|
184
|
+
flags: (bot.flags & EDITABLE_INTENT_FLAGS) | MESSAGE_CONTENT_LIMITED,
|
|
185
|
+
});
|
|
186
|
+
flags = isRecord(updated) && typeof updated.flags === "number" ? updated.flags : 0;
|
|
187
|
+
} catch {
|
|
188
|
+
// Verified or large applications must use the portal toggle.
|
|
189
|
+
}
|
|
190
|
+
if (!(flags & (MESSAGE_CONTENT | MESSAGE_CONTENT_LIMITED))) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Enable Message Content Intent at https://discord.com/developers/applications/${bot.applicationId}/bot, then rerun`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
io.say("Enabled Message Content Intent for the application.");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function waitForDiscordGuilds(
|
|
199
|
+
api: DiscordApiFn,
|
|
200
|
+
inviteUrl: string,
|
|
201
|
+
io: DiscordSetupIO,
|
|
202
|
+
sleep: (ms: number) => Promise<void>,
|
|
203
|
+
waitMs: number,
|
|
204
|
+
): Promise<Record<string, unknown>[]> {
|
|
205
|
+
let guilds = records(await api("GET", "/users/@me/guilds"));
|
|
206
|
+
if (guilds.length) return guilds;
|
|
207
|
+
if (!io.interactive) throw new Error(`Invite the bot to a server, then rerun: ${inviteUrl}`);
|
|
208
|
+
io.say(`Invite the bot to your server (choose the server and authorize):\n ${inviteUrl}`);
|
|
209
|
+
io.say("Waiting for the bot to join…");
|
|
210
|
+
for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
|
|
211
|
+
await sleep(INVITE_POLL_MS);
|
|
212
|
+
guilds = records(await api("GET", "/users/@me/guilds"));
|
|
213
|
+
if (guilds.length) return guilds;
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`The bot has not joined a server yet; invite it, then rerun: ${inviteUrl}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Discord's documented order: @everyone and role bits, Administrator, then @everyone/role/member overwrites. */
|
|
219
|
+
export function discordChannelPermissions(
|
|
220
|
+
guild: Record<string, unknown>,
|
|
221
|
+
memberRoleIds: string[],
|
|
222
|
+
channel: Record<string, unknown>,
|
|
223
|
+
botId: string,
|
|
224
|
+
): bigint {
|
|
225
|
+
if (guild.owner_id === botId) return EVERYTHING;
|
|
226
|
+
const guildId = str(guild.id);
|
|
227
|
+
const parseBits = (value: unknown): bigint =>
|
|
228
|
+
typeof value === "string" && /^\d+$/.test(value) ? BigInt(value) : 0n;
|
|
229
|
+
const roles = new Map(records(guild.roles).map(role => [str(role.id), parseBits(role.permissions)]));
|
|
230
|
+
let permissions = roles.get(guildId) ?? 0n;
|
|
231
|
+
for (const id of memberRoleIds) permissions |= roles.get(id) ?? 0n;
|
|
232
|
+
if (permissions & ADMINISTRATOR) return EVERYTHING;
|
|
233
|
+
const overwrites = records(channel.permission_overwrites);
|
|
234
|
+
const everyone = overwrites.find(overwrite => overwrite.id === guildId);
|
|
235
|
+
if (everyone) permissions = (permissions & ~parseBits(everyone.deny)) | parseBits(everyone.allow);
|
|
236
|
+
let allow = 0n;
|
|
237
|
+
let deny = 0n;
|
|
238
|
+
for (const overwrite of overwrites) {
|
|
239
|
+
if (overwrite.type === 0 && memberRoleIds.includes(str(overwrite.id) ?? "")) {
|
|
240
|
+
allow |= parseBits(overwrite.allow);
|
|
241
|
+
deny |= parseBits(overwrite.deny);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
permissions = (permissions & ~deny) | allow;
|
|
245
|
+
const member = overwrites.find(overwrite => overwrite.type === 1 && overwrite.id === botId);
|
|
246
|
+
if (member) permissions = (permissions & ~parseBits(member.deny)) | parseBits(member.allow);
|
|
247
|
+
return permissions;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function selectDiscordChannels(
|
|
251
|
+
api: DiscordApiFn,
|
|
252
|
+
bot: DiscordBotInfo,
|
|
253
|
+
guilds: Record<string, unknown>[],
|
|
254
|
+
requested: string[] | undefined,
|
|
255
|
+
inviteUrl: string,
|
|
256
|
+
io: DiscordSetupIO,
|
|
257
|
+
): Promise<RequestChannel[]> {
|
|
258
|
+
const guildCache = new Map<
|
|
259
|
+
string,
|
|
260
|
+
{ guild: Record<string, unknown>; roles: string[]; channels: Record<string, unknown>[] }
|
|
261
|
+
>();
|
|
262
|
+
const loadGuild = async (guildId: string) => {
|
|
263
|
+
let entry = guildCache.get(guildId);
|
|
264
|
+
if (!entry) {
|
|
265
|
+
const [guild, member, channels] = await Promise.all([
|
|
266
|
+
api("GET", `/guilds/${guildId}`),
|
|
267
|
+
api("GET", `/guilds/${guildId}/members/${bot.id}`),
|
|
268
|
+
api("GET", `/guilds/${guildId}/channels`),
|
|
269
|
+
]);
|
|
270
|
+
entry = {
|
|
271
|
+
guild: isRecord(guild) ? guild : {},
|
|
272
|
+
roles:
|
|
273
|
+
isRecord(member) && Array.isArray(member.roles) ? member.roles.filter(id => typeof id === "string") : [],
|
|
274
|
+
channels: records(channels),
|
|
275
|
+
};
|
|
276
|
+
guildCache.set(guildId, entry);
|
|
277
|
+
}
|
|
278
|
+
return entry;
|
|
279
|
+
};
|
|
280
|
+
const guildIds = new Set(guilds.map(guild => str(guild.id)));
|
|
281
|
+
const nameOf = (guildId: string): string => str(guilds.find(guild => guild.id === guildId)?.name) ?? guildId;
|
|
282
|
+
const missingOf = (permissions: bigint): string[] =>
|
|
283
|
+
DISCORD_REQUIRED_PERMISSIONS.filter(([, bit]) => !(permissions & bit)).map(([name]) => name);
|
|
284
|
+
|
|
285
|
+
if (requested?.length) {
|
|
286
|
+
const selected: RequestChannel[] = [];
|
|
287
|
+
for (const id of requested) {
|
|
288
|
+
let channel: unknown;
|
|
289
|
+
try {
|
|
290
|
+
channel = await api("GET", `/channels/${id}`);
|
|
291
|
+
} catch {
|
|
292
|
+
throw new Error(`The bot cannot see channel ${id}; invite it to that server: ${inviteUrl}`);
|
|
293
|
+
}
|
|
294
|
+
if (!isRecord(channel) || !str(channel.guild_id) || !guildIds.has(str(channel.guild_id))) {
|
|
295
|
+
throw new Error(`Channel ${id} is not in a server the bot has joined; invite it: ${inviteUrl}`);
|
|
296
|
+
}
|
|
297
|
+
if (typeof channel.type !== "number" || (!Object.hasOwn(REQUEST_CHANNEL_TYPES, channel.type) && !Object.hasOwn(THREAD_TYPES, channel.type))) {
|
|
298
|
+
throw new Error(`Channel ${id} must be a text channel or thread`);
|
|
299
|
+
}
|
|
300
|
+
if (Object.hasOwn(THREAD_TYPES, channel.type) && !str(channel.parent_id)) throw new Error(`Thread ${id} has no parent channel`);
|
|
301
|
+
const guildId = channel.guild_id as string;
|
|
302
|
+
const { guild, roles, channels } = await loadGuild(guildId);
|
|
303
|
+
const targetId =
|
|
304
|
+
typeof channel.type === "number" && Object.hasOwn(THREAD_TYPES, channel.type) ? channel.parent_id : id;
|
|
305
|
+
const target = channels.find(candidate => candidate.id === targetId) ?? channel;
|
|
306
|
+
const missing = missingOf(discordChannelPermissions(guild, roles, target, bot.id));
|
|
307
|
+
if (missing.length) throw new Error(`The bot lacks ${missing.join(", ")} in #${str(channel.name) ?? id}`);
|
|
308
|
+
selected.push({ id, name: str(channel.name) ?? id, guildName: nameOf(guildId) });
|
|
309
|
+
}
|
|
310
|
+
return selected;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const candidates: RequestChannel[] = [];
|
|
314
|
+
for (const guildId of guildIds) {
|
|
315
|
+
if (!guildId) continue;
|
|
316
|
+
const { guild, roles, channels } = await loadGuild(guildId);
|
|
317
|
+
for (const channel of channels) {
|
|
318
|
+
if (
|
|
319
|
+
typeof channel.type !== "number" ||
|
|
320
|
+
!Object.hasOwn(REQUEST_CHANNEL_TYPES, channel.type) ||
|
|
321
|
+
!str(channel.id)
|
|
322
|
+
)
|
|
323
|
+
continue;
|
|
324
|
+
if (missingOf(discordChannelPermissions(guild, roles, channel, bot.id)).length) continue;
|
|
325
|
+
candidates.push({
|
|
326
|
+
id: channel.id as string,
|
|
327
|
+
name: str(channel.name) ?? (channel.id as string),
|
|
328
|
+
guildName: nameOf(guildId),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (!candidates.length) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`No text channel grants the bot ${DISCORD_REQUIRED_PERMISSIONS.map(([name]) => name).join(", ")}; grant them or pass --channel`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (candidates.length === 1) return candidates;
|
|
338
|
+
const listing = candidates.map(
|
|
339
|
+
(channel, index) => ` ${index + 1}. #${channel.name} in ${channel.guildName} (${channel.id})`,
|
|
340
|
+
);
|
|
341
|
+
if (!io.interactive) throw new Error(`Several channels qualify; pass --channel <id>:\n${listing.join("\n")}`);
|
|
342
|
+
io.say(`Channels where the bot can work:\n${listing.join("\n")}`);
|
|
343
|
+
const answer = (await io.ask("Request channel number [1]: ")).trim();
|
|
344
|
+
const index = answer ? Number.parseInt(answer, 10) - 1 : 0;
|
|
345
|
+
const choice = candidates[index];
|
|
346
|
+
if (!choice) throw new Error("No channel selected");
|
|
347
|
+
return [choice];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function openOwnerDm(api: DiscordApiFn, userId: string, io: DiscordSetupIO): Promise<string | undefined> {
|
|
351
|
+
try {
|
|
352
|
+
const channel = await api("POST", "/users/@me/channels", { recipient_id: userId });
|
|
353
|
+
const id = isRecord(channel) ? str(channel.id) : undefined;
|
|
354
|
+
if (id) return id;
|
|
355
|
+
} catch {
|
|
356
|
+
// Reported below; server requests still work.
|
|
357
|
+
}
|
|
358
|
+
io.say(`Could not open a DM with ${userId}; DMs to the bot will be ignored until its DM channel is allowed.`);
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export async function probeDiscordGateway(
|
|
363
|
+
api: DiscordApiFn,
|
|
364
|
+
token: string,
|
|
365
|
+
WebSocketImpl: typeof WebSocket,
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
const discovery = await api("GET", "/gateway/bot");
|
|
368
|
+
const url = discordGatewayUrl(isRecord(discovery) ? discovery.url : undefined);
|
|
369
|
+
const result = Promise.withResolvers<void>();
|
|
370
|
+
const socket = new WebSocketImpl(url);
|
|
371
|
+
const timer = setTimeout(
|
|
372
|
+
() => result.reject(new Error("Discord Gateway did not confirm the bot session in time")),
|
|
373
|
+
GATEWAY_PROBE_MS,
|
|
374
|
+
);
|
|
375
|
+
socket.addEventListener("message", (event: MessageEvent) => {
|
|
376
|
+
let packet: unknown;
|
|
377
|
+
try {
|
|
378
|
+
packet = typeof event.data === "string" ? JSON.parse(event.data) : undefined;
|
|
379
|
+
} catch {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (!isRecord(packet)) return;
|
|
383
|
+
if (packet.op === 10) {
|
|
384
|
+
socket.send(
|
|
385
|
+
JSON.stringify({
|
|
386
|
+
op: 2,
|
|
387
|
+
d: {
|
|
388
|
+
token,
|
|
389
|
+
intents: DISCORD_GATEWAY_INTENTS,
|
|
390
|
+
properties: { os: process.platform, browser: "jeo", device: "jeo" },
|
|
391
|
+
},
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
} else if (packet.op === 0 && packet.t === "READY") {
|
|
395
|
+
result.resolve();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
socket.addEventListener("close", (event: CloseEvent) => {
|
|
399
|
+
if (event.code === 4004) result.reject(new Error("Discord Gateway rejected the bot token (4004)"));
|
|
400
|
+
else if (event.code === 4013 || event.code === 4014) {
|
|
401
|
+
result.reject(new Error("Discord Gateway refused the message intents (4014); enable Message Content Intent"));
|
|
402
|
+
} else result.reject(new Error(`Discord Gateway closed before the session was ready (${event.code})`));
|
|
403
|
+
});
|
|
404
|
+
socket.addEventListener("error", () => result.reject(new Error("Discord Gateway connection failed")));
|
|
405
|
+
try {
|
|
406
|
+
await result.promise;
|
|
407
|
+
} finally {
|
|
408
|
+
clearTimeout(timer);
|
|
409
|
+
try {
|
|
410
|
+
socket.close(1000);
|
|
411
|
+
} catch {
|
|
412
|
+
// Already closed.
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The token is read from the 0600 file at launch, so it never lands in this script or a service definition. */
|
|
418
|
+
export function discordRunScript(command: string[], configName: string, tokenEnv: string, envPath: string): string {
|
|
419
|
+
const quote = (value: string): string => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
420
|
+
return `#!/bin/sh
|
|
421
|
+
# Generated by \`jeo messenger setup\`: starts this profile's Discord gateway.
|
|
422
|
+
set -eu
|
|
423
|
+
dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
424
|
+
token_file="$dir/${TOKEN_FILE}"
|
|
425
|
+
case "$(ls -ln "$token_file" | cut -c5-10)" in
|
|
426
|
+
------) ;;
|
|
427
|
+
*) echo "${TOKEN_FILE} must be readable by its owner only (chmod 600)" >&2; exit 1 ;;
|
|
428
|
+
esac
|
|
429
|
+
PATH=${quote(envPath)}
|
|
430
|
+
export PATH
|
|
431
|
+
${tokenEnv}=$(cat "$token_file")
|
|
432
|
+
export ${tokenEnv}
|
|
433
|
+
exec ${command.map(quote).join(" ")} messenger run --config "$dir/${configName}"
|
|
434
|
+
`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function writePrivate(file: string, content: string, mode: number): Promise<void> {
|
|
438
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
439
|
+
await Bun.write(temporary, content);
|
|
440
|
+
await fs.chmod(temporary, mode);
|
|
441
|
+
await fs.rename(temporary, file);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function readExistingConfig(configPath: string): Promise<Record<string, unknown> | undefined> {
|
|
445
|
+
let text: string;
|
|
446
|
+
try {
|
|
447
|
+
text = await Bun.file(configPath).text();
|
|
448
|
+
} catch (error) {
|
|
449
|
+
if (isEnoent(error)) return undefined;
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const value: unknown = JSON.parse(text);
|
|
454
|
+
if (isRecord(value)) return value;
|
|
455
|
+
} catch {
|
|
456
|
+
// Fall through to the refusal below.
|
|
457
|
+
}
|
|
458
|
+
throw new Error(`${configPath} exists but is not a JSON object; fix or move it before setup`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function currentJeoCommand(): string[] {
|
|
462
|
+
const entry = Bun.main;
|
|
463
|
+
return entry === process.execPath || entry.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, entry];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Create or repair a Discord messenger profile with as few manual steps as Discord allows. */
|
|
467
|
+
export async function setupDiscordMessenger(
|
|
468
|
+
options: DiscordSetupOptions,
|
|
469
|
+
io: DiscordSetupIO,
|
|
470
|
+
deps: DiscordSetupDeps = {},
|
|
471
|
+
): Promise<DiscordSetupResult> {
|
|
472
|
+
const env = deps.env ?? process.env;
|
|
473
|
+
const configPath = path.resolve(options.configPath);
|
|
474
|
+
const directory = path.dirname(configPath);
|
|
475
|
+
const tokenPath = path.join(directory, TOKEN_FILE);
|
|
476
|
+
const runScript = path.join(directory, RUN_SCRIPT);
|
|
477
|
+
|
|
478
|
+
const token = await resolveToken(env, tokenPath, io);
|
|
479
|
+
const api = discordApi(token, deps.fetch ?? fetch);
|
|
480
|
+
const bot = await inspectDiscordBot(api);
|
|
481
|
+
io.say(`Bot ${bot.username} (${bot.id}), application ${bot.applicationId}`);
|
|
482
|
+
await ensureMessageContentIntent(api, bot, io);
|
|
483
|
+
|
|
484
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
485
|
+
await writePrivate(tokenPath, `${token}\n`, 0o600);
|
|
486
|
+
|
|
487
|
+
const inviteUrl = discordInviteUrl(bot.applicationId);
|
|
488
|
+
const guilds = await waitForDiscordGuilds(api, inviteUrl, io, deps.sleep ?? Bun.sleep, options.inviteWaitMs ?? 300_000);
|
|
489
|
+
const existing = await readExistingConfig(configPath);
|
|
490
|
+
const platforms = isRecord(existing?.platforms) ? existing.platforms : {};
|
|
491
|
+
const previous = isRecord(platforms.discord) ? platforms.discord : {};
|
|
492
|
+
const previousList = (key: string): string[] | undefined => {
|
|
493
|
+
const value = previous[key];
|
|
494
|
+
return Array.isArray(value) && value.length ? value.filter(id => typeof id === "string") : undefined;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const userIds = options.userIds?.length ? options.userIds : (previousList("allowedUserIds") ?? bot.ownerIds);
|
|
498
|
+
if (!userIds.length) throw new Error("Could not identify the application owner; pass --user <your Discord user id>");
|
|
499
|
+
let channelIds: string[];
|
|
500
|
+
if (options.channelIds?.length || !previousList("allowedChannelIds")) {
|
|
501
|
+
const channels = await selectDiscordChannels(api, bot, guilds, options.channelIds, inviteUrl, io);
|
|
502
|
+
for (const channel of channels) io.say(`Request channel: #${channel.name} in ${channel.guildName}`);
|
|
503
|
+
channelIds = channels.map(channel => channel.id);
|
|
504
|
+
} else {
|
|
505
|
+
channelIds = previousList("allowedChannelIds") as string[];
|
|
506
|
+
}
|
|
507
|
+
const dmChannel = options.dmTest === false ? undefined : await openOwnerDm(api, userIds[0]!, io);
|
|
508
|
+
const allowedChannelIds = [...new Set([...channelIds, ...(dmChannel ? [dmChannel] : [])])];
|
|
509
|
+
const tokenEnv = str(previous.tokenEnv) ?? DISCORD_TOKEN_ENV;
|
|
510
|
+
|
|
511
|
+
const discord: Record<string, unknown> = { ...previous, tokenEnv, allowedUserIds: userIds, allowedChannelIds };
|
|
512
|
+
if (options.artifacts) discord.artifactDir = "./artifacts";
|
|
513
|
+
const config = {
|
|
514
|
+
...existing,
|
|
515
|
+
cwd: str(existing?.cwd) ?? "./workspace",
|
|
516
|
+
sessionDir: str(existing?.sessionDir) ?? "./sessions",
|
|
517
|
+
toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : []),
|
|
518
|
+
platforms: { ...platforms, discord },
|
|
519
|
+
};
|
|
520
|
+
for (const relative of [config.cwd, config.sessionDir, discord.artifactDir]) {
|
|
521
|
+
if (typeof relative === "string")
|
|
522
|
+
await fs.mkdir(path.resolve(directory, relative), { recursive: true, mode: 0o700 });
|
|
523
|
+
}
|
|
524
|
+
await writePrivate(configPath, `${JSON.stringify(config, null, 2)}\n`, 0o600);
|
|
525
|
+
const envPath = [...new Set([path.dirname(process.execPath), ...(env.PATH ?? "/usr/bin:/bin").split(":")])].join(
|
|
526
|
+
":",
|
|
527
|
+
);
|
|
528
|
+
await writePrivate(
|
|
529
|
+
runScript,
|
|
530
|
+
discordRunScript(deps.jeoCommand ?? currentJeoCommand(), path.basename(configPath), tokenEnv, envPath),
|
|
531
|
+
0o700,
|
|
532
|
+
);
|
|
533
|
+
// The same loader `messenger run` uses must accept what was written.
|
|
534
|
+
const written = await loadMessengerConfig(configPath);
|
|
535
|
+
validateMessengerEnvironment(
|
|
536
|
+
{ ...written, platforms: { discord: written.platforms.discord } },
|
|
537
|
+
{ [tokenEnv]: token },
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
await probeDiscordGateway(api, token, deps.WebSocket ?? WebSocket);
|
|
541
|
+
io.say("Discord Gateway accepted the bot session and intents.");
|
|
542
|
+
|
|
543
|
+
let service: string | undefined;
|
|
544
|
+
if (options.service) {
|
|
545
|
+
service = await (deps.installService ?? installMessengerService)(messengerServicePlan(directory, runScript));
|
|
546
|
+
io.say(`Gateway running as ${service}`);
|
|
547
|
+
}
|
|
548
|
+
if (dmChannel) {
|
|
549
|
+
try {
|
|
550
|
+
await api("POST", `/channels/${dmChannel}/messages`, { content: DM_TEST_MESSAGE });
|
|
551
|
+
io.say("Sent a test DM to the owner.");
|
|
552
|
+
} catch {
|
|
553
|
+
io.say(
|
|
554
|
+
"Could not send the test DM (the owner may block DMs from server members); server requests still work.",
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
configPath,
|
|
560
|
+
botName: bot.username,
|
|
561
|
+
applicationId: bot.applicationId,
|
|
562
|
+
inviteUrl,
|
|
563
|
+
allowedUserIds: userIds,
|
|
564
|
+
allowedChannelIds,
|
|
565
|
+
runScript,
|
|
566
|
+
...(service ? { service } : {}),
|
|
567
|
+
};
|
|
568
|
+
}
|