@elizaos/plugin-discord-local 2.0.0-beta.1

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/dist/index.js ADDED
@@ -0,0 +1,1033 @@
1
+ // src/index.ts
2
+ import { execFile } from "node:child_process";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs";
5
+ import fsp from "node:fs/promises";
6
+ import net from "node:net";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { promisify } from "node:util";
10
+ import {
11
+ ChannelType,
12
+ ContentType,
13
+ createMessageMemory,
14
+ createUniqueUuid,
15
+ logger,
16
+ resolveStateDir,
17
+ Service,
18
+ stringToUuid
19
+ } from "@elizaos/core";
20
+ var execFileAsync = promisify(execFile);
21
+ var DISCORD_LOCAL_PLUGIN_NAME = "@elizaos/plugin-discord-local";
22
+ var DISCORD_LOCAL_SERVICE_NAME = "discord-local";
23
+ var DISCORD_OAUTH_TOKEN_URL = "https://discord.com/api/v10/oauth2/token";
24
+ var DISCORD_LOCAL_DEFAULT_SCOPES = ["rpc", "identify", "rpc.notifications.read"];
25
+ var IPC_OP_HANDSHAKE = 0;
26
+ var IPC_OP_FRAME = 1;
27
+ var IPC_OP_CLOSE = 2;
28
+ var IPC_OP_PING = 3;
29
+ var IPC_OP_PONG = 4;
30
+ function parseListSetting(value) {
31
+ if (Array.isArray(value)) {
32
+ return value.map((entry) => typeof entry === "string" ? entry.trim() : "").filter((entry) => entry.length > 0);
33
+ }
34
+ if (typeof value === "string") {
35
+ return value.split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
36
+ }
37
+ return [];
38
+ }
39
+ function getDiscordLocalConfig(runtime) {
40
+ const clientId = runtime.getSetting("DISCORD_LOCAL_CLIENT_ID");
41
+ const clientSecret = runtime.getSetting("DISCORD_LOCAL_CLIENT_SECRET");
42
+ const enabledValue = runtime.getSetting("DISCORD_LOCAL_ENABLED");
43
+ const enabled = enabledValue === undefined || enabledValue === null || typeof enabledValue === "string" && enabledValue.trim() !== "false";
44
+ if (typeof clientId !== "string" || clientId.trim().length === 0 || typeof clientSecret !== "string" || clientSecret.trim().length === 0) {
45
+ return null;
46
+ }
47
+ const rawSendDelayMs = runtime.getSetting("DISCORD_LOCAL_SEND_DELAY_MS");
48
+ const parsedSendDelayMs = typeof rawSendDelayMs === "string" ? Number.parseInt(rawSendDelayMs, 10) : Number.NaN;
49
+ return {
50
+ enabled,
51
+ clientId: clientId.trim(),
52
+ clientSecret: clientSecret.trim(),
53
+ scopes: (() => {
54
+ const parsed = parseListSetting(runtime.getSetting("DISCORD_LOCAL_SCOPES"));
55
+ return parsed.length > 0 ? parsed : [...DISCORD_LOCAL_DEFAULT_SCOPES];
56
+ })(),
57
+ messageChannelIds: parseListSetting(runtime.getSetting("DISCORD_LOCAL_MESSAGE_CHANNEL_IDS")),
58
+ sendDelayMs: Number.isFinite(parsedSendDelayMs) && parsedSendDelayMs >= 100 ? parsedSendDelayMs : 900
59
+ };
60
+ }
61
+ function resolveSessionPath() {
62
+ const dir = path.join(resolveStateDir(), "discord-local");
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ return path.join(dir, "session.json");
65
+ }
66
+ function buildDiscordAvatarUrl(user) {
67
+ if (!user?.avatar || !user.id) {
68
+ return;
69
+ }
70
+ return `https://cdn.discordapp.com/avatars/${encodeURIComponent(user.id)}/${encodeURIComponent(user.avatar)}.png?size=128`;
71
+ }
72
+ function roomTypeForChannel(channelType) {
73
+ if (channelType === 1) {
74
+ return ChannelType.DM;
75
+ }
76
+ return ChannelType.GROUP;
77
+ }
78
+ function worldIdFor(runtime, serverKey) {
79
+ return stringToUuid(`discord-local-world:${runtime.agentId}:${serverKey}`);
80
+ }
81
+ function roomIdFor(runtime, channelId) {
82
+ return stringToUuid(`discord-local-room:${runtime.agentId}:${channelId}`);
83
+ }
84
+ function entityIdFor(userId) {
85
+ return stringToUuid(`discord-local-user:${userId}`);
86
+ }
87
+ function messageIdFor(runtime, channelId, messageId) {
88
+ return stringToUuid(`discord-local-message:${runtime.agentId}:${channelId}:${messageId}`);
89
+ }
90
+ function outboundMemoryIdFor(runtime, roomId) {
91
+ return createUniqueUuid(runtime, `discord-local-outbound:${roomId}:${Date.now()}`);
92
+ }
93
+ function getRegisteredSendHandlers(runtime) {
94
+ const sendHandlers = runtime.sendHandlers;
95
+ return sendHandlers instanceof Map ? sendHandlers : null;
96
+ }
97
+ function contentTypeForMime(mimeType) {
98
+ const normalized = mimeType?.trim().toLowerCase();
99
+ if (!normalized) {
100
+ return;
101
+ }
102
+ if (normalized.startsWith("image/")) {
103
+ return ContentType.IMAGE;
104
+ }
105
+ if (normalized.startsWith("video/")) {
106
+ return ContentType.VIDEO;
107
+ }
108
+ if (normalized.startsWith("audio/")) {
109
+ return ContentType.AUDIO;
110
+ }
111
+ if (normalized === "text/uri-list") {
112
+ return ContentType.LINK;
113
+ }
114
+ return ContentType.DOCUMENT;
115
+ }
116
+ function describeRpcError(payload) {
117
+ const data = payload.data && typeof payload.data === "object" ? payload.data : null;
118
+ const message = typeof data?.message === "string" ? data.message : typeof data?.error === "string" ? data.error : payload.cmd ? `Discord RPC command ${payload.cmd} failed` : "Discord RPC request failed";
119
+ const code = typeof data?.code === "number" || typeof data?.code === "string" ? ` (${String(data.code)})` : "";
120
+ return `${message}${code}`;
121
+ }
122
+ function toAppleScriptStringLiteral(value) {
123
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
124
+ }
125
+ function buildDiscordSendScript(text, delaySeconds) {
126
+ const lines = text.split(/\r?\n/);
127
+ const scriptLines = [
128
+ 'tell application "Discord" to activate',
129
+ `delay ${delaySeconds.toFixed(2)}`,
130
+ 'tell application "System Events"'
131
+ ];
132
+ for (const [index, line] of lines.entries()) {
133
+ if (line.length > 0) {
134
+ scriptLines.push(` keystroke ${toAppleScriptStringLiteral(line)}`);
135
+ }
136
+ if (index < lines.length - 1) {
137
+ scriptLines.push(" key code 36 using shift down");
138
+ }
139
+ }
140
+ scriptLines.push(" key code 36");
141
+ scriptLines.push("end tell");
142
+ return scriptLines.join(`
143
+ `);
144
+ }
145
+ async function openDiscordTarget(channelId, guildId) {
146
+ const url = guildId && guildId.trim().length > 0 ? `discord://-/channels/${guildId}/${channelId}` : `discord://-/channels/@me/${channelId}`;
147
+ await execFileAsync("/usr/bin/open", [url]);
148
+ }
149
+ function getIpcCandidateDirs() {
150
+ const candidates = [
151
+ process.env.DISCORD_IPC_DIR,
152
+ process.env.XDG_RUNTIME_DIR,
153
+ process.env.TMPDIR,
154
+ process.env.TMP,
155
+ process.env.TEMP,
156
+ "/tmp",
157
+ "/private/tmp"
158
+ ].filter((entry) => typeof entry === "string" && entry.trim().length > 0);
159
+ return Array.from(new Set(candidates.map((entry) => entry.trim())));
160
+ }
161
+ function findDiscordIpcPath() {
162
+ for (const dir of getIpcCandidateDirs()) {
163
+ for (let index = 0;index < 10; index += 1) {
164
+ const candidate = path.join(dir, `discord-ipc-${index}`);
165
+ if (fs.existsSync(candidate)) {
166
+ return candidate;
167
+ }
168
+ }
169
+ }
170
+ const macRoot = path.join(os.homedir(), "Library", "Application Support", "discord");
171
+ if (!fs.existsSync(macRoot)) {
172
+ return null;
173
+ }
174
+ const stack = [macRoot];
175
+ while (stack.length > 0) {
176
+ const current = stack.pop();
177
+ if (!current)
178
+ continue;
179
+ const entries = fs.readdirSync(current, { withFileTypes: true });
180
+ for (const entry of entries) {
181
+ const candidatePath = path.join(current, entry.name);
182
+ if (entry.isDirectory()) {
183
+ stack.push(candidatePath);
184
+ continue;
185
+ }
186
+ if (entry.isSocket() && /^discord-ipc-\d+$/.test(entry.name)) {
187
+ return candidatePath;
188
+ }
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ class DiscordLocalService extends Service {
195
+ static serviceType = DISCORD_LOCAL_SERVICE_NAME;
196
+ capabilityDescription = "The agent can read Discord notifications and channel messages from the local Discord desktop app and send replies through macOS UI automation.";
197
+ sessionPath = resolveSessionPath();
198
+ pendingRequests = new Map;
199
+ channelCache = new Map;
200
+ guildCache = new Map;
201
+ subscribedChannelIds = new Set;
202
+ connectorConfig = null;
203
+ socket = null;
204
+ connectedIpcPath = null;
205
+ readBuffer = Buffer.alloc(0);
206
+ readyPromise = null;
207
+ readyResolve = null;
208
+ readyReject = null;
209
+ reconnectTimer = null;
210
+ session = null;
211
+ currentUser = null;
212
+ connected = false;
213
+ authenticated = false;
214
+ lastError = null;
215
+ constructor(runtime) {
216
+ super(runtime);
217
+ if (!runtime) {
218
+ return;
219
+ }
220
+ this.connectorConfig = getDiscordLocalConfig(runtime);
221
+ }
222
+ static async start(runtime) {
223
+ const service = new DiscordLocalService(runtime);
224
+ await service.startService();
225
+ return service;
226
+ }
227
+ static registerSendHandlers(runtime, service) {
228
+ const register = (source) => {
229
+ runtime.registerSendHandler(source, async (_runtime, target, content) => {
230
+ const text = typeof content.text === "string" ? content.text.trim() : "";
231
+ if (!text) {
232
+ return;
233
+ }
234
+ const room = target.roomId && typeof runtime.getRoom === "function" ? await runtime.getRoom(target.roomId) : null;
235
+ const channelId = String(target.channelId ?? room?.channelId ?? "").trim();
236
+ if (!channelId) {
237
+ throw new Error("Discord local target is missing a channel ID");
238
+ }
239
+ const channel = await service.getChannel(channelId);
240
+ const guildId = channel?.guild_id && channel.guild_id.trim().length > 0 ? channel.guild_id : null;
241
+ await service.sendUiMessage(channelId, guildId, text);
242
+ if (!target.roomId) {
243
+ return;
244
+ }
245
+ const memory = createMessageMemory({
246
+ id: outboundMemoryIdFor(runtime, target.roomId),
247
+ agentId: runtime.agentId,
248
+ entityId: runtime.agentId,
249
+ roomId: target.roomId,
250
+ content: {
251
+ ...content,
252
+ text,
253
+ source: DISCORD_LOCAL_SERVICE_NAME
254
+ }
255
+ });
256
+ memory.createdAt = Date.now();
257
+ memory.metadata = {
258
+ ...memory.metadata,
259
+ discordChannelId: channelId,
260
+ ...guildId ? { discordServerId: guildId } : {}
261
+ };
262
+ await runtime.createMemory(memory, "messages");
263
+ });
264
+ };
265
+ register(DISCORD_LOCAL_SERVICE_NAME);
266
+ const sendHandlers = getRegisteredSendHandlers(runtime);
267
+ if (!(sendHandlers instanceof Map) || !sendHandlers.has("discord")) {
268
+ register("discord");
269
+ }
270
+ }
271
+ async stop() {
272
+ if (this.reconnectTimer) {
273
+ clearTimeout(this.reconnectTimer);
274
+ this.reconnectTimer = null;
275
+ }
276
+ this.connected = false;
277
+ this.authenticated = false;
278
+ this.connectedIpcPath = null;
279
+ this.rejectPendingRequests(new Error("Discord local service stopped"));
280
+ this.socket?.destroy();
281
+ this.socket = null;
282
+ }
283
+ isConnected() {
284
+ return this.connected;
285
+ }
286
+ isAuthenticated() {
287
+ return this.authenticated;
288
+ }
289
+ getStatus() {
290
+ return {
291
+ available: Boolean(this.connectorConfig),
292
+ connected: this.connected,
293
+ authenticated: this.authenticated,
294
+ currentUser: this.currentUser,
295
+ subscribedChannelIds: [...this.subscribedChannelIds],
296
+ configuredChannelIds: this.connectorConfig?.messageChannelIds ?? [],
297
+ scopes: this.session?.scopes ?? this.connectorConfig?.scopes ?? [],
298
+ lastError: this.lastError,
299
+ ipcPath: this.connected ? this.connectedIpcPath : findDiscordIpcPath()
300
+ };
301
+ }
302
+ async authorize() {
303
+ const config = this.requireConfig();
304
+ await this.ensureRpcConnection();
305
+ const response = await this.sendRpcCommand("AUTHORIZE", {
306
+ client_id: config.clientId,
307
+ scopes: config.scopes
308
+ });
309
+ const code = response.data && typeof response.data === "object" && typeof response.data.code === "string" ? response.data.code : "";
310
+ if (!code) {
311
+ throw new Error("Discord AUTHORIZE did not return an authorization code");
312
+ }
313
+ await this.exchangeAuthorizationCode(code);
314
+ return this.getStatus();
315
+ }
316
+ async disconnectSession() {
317
+ this.session = null;
318
+ this.currentUser = null;
319
+ this.authenticated = false;
320
+ this.subscribedChannelIds.clear();
321
+ await fsp.rm(this.sessionPath, { force: true });
322
+ await this.stop();
323
+ }
324
+ async listGuilds() {
325
+ await this.ensureAuthenticated();
326
+ const response = await this.sendRpcCommand("GET_GUILDS");
327
+ const guilds = Array.isArray(response.data) ? response.data : [];
328
+ for (const guild of guilds) {
329
+ if (guild?.id) {
330
+ this.guildCache.set(guild.id, guild);
331
+ }
332
+ }
333
+ return guilds;
334
+ }
335
+ async listChannels(guildId) {
336
+ await this.ensureAuthenticated();
337
+ const response = await this.sendRpcCommand("GET_CHANNELS", {
338
+ guild_id: guildId
339
+ });
340
+ const channels = Array.isArray(response.data) ? response.data : [];
341
+ for (const channel of channels) {
342
+ if (channel?.id) {
343
+ this.channelCache.set(channel.id, channel);
344
+ }
345
+ }
346
+ return channels;
347
+ }
348
+ async subscribeChannelMessages(channelIds) {
349
+ const config = this.requireConfig();
350
+ await this.ensureAuthenticated();
351
+ const normalized = [...new Set(channelIds.map((entry) => entry.trim()).filter(Boolean))];
352
+ config.messageChannelIds = normalized;
353
+ for (const channelId of Array.from(this.subscribedChannelIds)) {
354
+ if (normalized.includes(channelId)) {
355
+ continue;
356
+ }
357
+ await this.sendRpcCommand("UNSUBSCRIBE", { channel_id: channelId }, "MESSAGE_CREATE");
358
+ this.subscribedChannelIds.delete(channelId);
359
+ }
360
+ for (const channelId of normalized) {
361
+ if (this.subscribedChannelIds.has(channelId)) {
362
+ continue;
363
+ }
364
+ await this.sendRpcCommand("SUBSCRIBE", { channel_id: channelId }, "MESSAGE_CREATE");
365
+ this.subscribedChannelIds.add(channelId);
366
+ }
367
+ return [...normalized];
368
+ }
369
+ async getChannel(channelId) {
370
+ const cached = this.channelCache.get(channelId);
371
+ if (cached) {
372
+ return cached;
373
+ }
374
+ await this.ensureAuthenticated();
375
+ const response = await this.sendRpcCommand("GET_CHANNEL", {
376
+ channel_id: channelId
377
+ });
378
+ const channel = response.data && typeof response.data === "object" ? response.data : null;
379
+ if (channel?.id) {
380
+ this.channelCache.set(channel.id, channel);
381
+ }
382
+ return channel;
383
+ }
384
+ requireConfig() {
385
+ if (!this.connectorConfig) {
386
+ throw new Error("Discord local connector is not configured");
387
+ }
388
+ if (process.platform !== "darwin") {
389
+ throw new Error("Discord local connector currently supports macOS only");
390
+ }
391
+ return this.connectorConfig;
392
+ }
393
+ async startService() {
394
+ if (!this.connectorConfig?.enabled) {
395
+ return;
396
+ }
397
+ this.session = await this.loadSession();
398
+ if (!this.session) {
399
+ return;
400
+ }
401
+ try {
402
+ await this.ensureAuthenticated();
403
+ } catch (error) {
404
+ this.lastError = error instanceof Error ? error.message : String(error);
405
+ logger.warn(`[discord-local] Failed to restore Discord local session: ${this.lastError}`);
406
+ }
407
+ }
408
+ async ensureAuthenticated() {
409
+ this.requireConfig();
410
+ if (!this.session) {
411
+ throw new Error("Discord local connector is not authorized");
412
+ }
413
+ await this.ensureRpcConnection();
414
+ const expiresAt = this.session.expiresAt ?? 0;
415
+ if (this.session.refreshToken && expiresAt > 0 && Date.now() >= expiresAt - 60000) {
416
+ await this.refreshAccessToken();
417
+ }
418
+ if (this.authenticated) {
419
+ return;
420
+ }
421
+ const response = await this.sendRpcCommand("AUTHENTICATE", {
422
+ access_token: this.session.accessToken
423
+ });
424
+ const rawUser = response.data && typeof response.data === "object" ? response.data.user : undefined;
425
+ this.currentUser = rawUser ?? null;
426
+ this.authenticated = true;
427
+ await this.subscribeNotifications();
428
+ await this.subscribeConfiguredChannels();
429
+ }
430
+ async subscribeNotifications() {
431
+ if (!this.session?.scopes.includes("rpc.notifications.read")) {
432
+ return;
433
+ }
434
+ await this.sendRpcCommand("SUBSCRIBE", {}, "NOTIFICATION_CREATE");
435
+ }
436
+ async subscribeConfiguredChannels() {
437
+ const channelIds = this.connectorConfig?.messageChannelIds ?? [];
438
+ for (const channelId of channelIds) {
439
+ if (this.subscribedChannelIds.has(channelId)) {
440
+ continue;
441
+ }
442
+ await this.sendRpcCommand("SUBSCRIBE", { channel_id: channelId }, "MESSAGE_CREATE");
443
+ this.subscribedChannelIds.add(channelId);
444
+ }
445
+ }
446
+ async exchangeAuthorizationCode(code) {
447
+ const config = this.requireConfig();
448
+ const body = new URLSearchParams({
449
+ client_id: config.clientId,
450
+ client_secret: config.clientSecret,
451
+ grant_type: "authorization_code",
452
+ code
453
+ });
454
+ const response = await fetch(DISCORD_OAUTH_TOKEN_URL, {
455
+ method: "POST",
456
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
457
+ body
458
+ });
459
+ if (!response.ok) {
460
+ throw new Error(`Discord OAuth token exchange failed with ${response.status}`);
461
+ }
462
+ const json = await response.json();
463
+ await this.storeTokenResponse(json);
464
+ this.authenticated = false;
465
+ await this.ensureAuthenticated();
466
+ }
467
+ async refreshAccessToken() {
468
+ const config = this.requireConfig();
469
+ if (!this.session?.refreshToken) {
470
+ throw new Error("Discord local session cannot be refreshed");
471
+ }
472
+ const body = new URLSearchParams({
473
+ client_id: config.clientId,
474
+ client_secret: config.clientSecret,
475
+ grant_type: "refresh_token",
476
+ refresh_token: this.session.refreshToken
477
+ });
478
+ const response = await fetch(DISCORD_OAUTH_TOKEN_URL, {
479
+ method: "POST",
480
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
481
+ body
482
+ });
483
+ if (!response.ok) {
484
+ throw new Error(`Discord OAuth refresh failed with ${response.status}`);
485
+ }
486
+ const json = await response.json();
487
+ await this.storeTokenResponse(json);
488
+ this.authenticated = false;
489
+ }
490
+ async storeTokenResponse(json) {
491
+ const accessToken = typeof json.access_token === "string" ? json.access_token : "";
492
+ if (!accessToken) {
493
+ throw new Error("Discord OAuth token response is missing access_token");
494
+ }
495
+ const refreshToken = typeof json.refresh_token === "string" ? json.refresh_token : undefined;
496
+ const expiresIn = typeof json.expires_in === "number" ? json.expires_in : typeof json.expires_in === "string" ? Number.parseInt(json.expires_in, 10) : 0;
497
+ const scopeString = typeof json.scope === "string" ? json.scope : this.connectorConfig?.scopes.join(" ") ?? "";
498
+ this.session = {
499
+ accessToken,
500
+ refreshToken,
501
+ expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1000 : undefined,
502
+ scopes: scopeString.split(/\s+/).map((entry) => entry.trim()).filter(Boolean)
503
+ };
504
+ await fsp.writeFile(this.sessionPath, JSON.stringify(this.session, null, 2), "utf8");
505
+ }
506
+ async loadSession() {
507
+ if (!fs.existsSync(this.sessionPath)) {
508
+ return null;
509
+ }
510
+ const raw = await fsp.readFile(this.sessionPath, "utf8");
511
+ const parsed = JSON.parse(raw);
512
+ if (typeof parsed.accessToken !== "string" || parsed.accessToken.trim().length === 0) {
513
+ return null;
514
+ }
515
+ return {
516
+ accessToken: parsed.accessToken,
517
+ refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
518
+ expiresAt: typeof parsed.expiresAt === "number" ? parsed.expiresAt : undefined,
519
+ scopes: Array.isArray(parsed.scopes) ? parsed.scopes.filter((entry) => typeof entry === "string") : [...DISCORD_LOCAL_DEFAULT_SCOPES]
520
+ };
521
+ }
522
+ async ensureRpcConnection() {
523
+ const config = this.requireConfig();
524
+ if (this.connected && this.socket && !this.socket.destroyed) {
525
+ return;
526
+ }
527
+ if (this.readyPromise) {
528
+ return this.readyPromise;
529
+ }
530
+ const ipcPath = findDiscordIpcPath();
531
+ if (!ipcPath) {
532
+ throw new Error("Discord IPC socket not found. Open the Discord desktop app first.");
533
+ }
534
+ this.readyPromise = new Promise((resolve, reject) => {
535
+ this.readyResolve = resolve;
536
+ this.readyReject = reject;
537
+ });
538
+ const socket = net.createConnection(ipcPath);
539
+ this.socket = socket;
540
+ this.readBuffer = Buffer.alloc(0);
541
+ socket.on("connect", () => {
542
+ this.connectedIpcPath = ipcPath;
543
+ this.writeFrame(IPC_OP_HANDSHAKE, {
544
+ v: 1,
545
+ client_id: config.clientId
546
+ });
547
+ });
548
+ socket.on("data", (chunk) => {
549
+ this.handleSocketData(chunk);
550
+ });
551
+ socket.on("close", () => {
552
+ const error = new Error("Discord local RPC connection closed");
553
+ this.connected = false;
554
+ this.authenticated = false;
555
+ this.connectedIpcPath = null;
556
+ this.socket = null;
557
+ this.rejectPendingRequests(error);
558
+ this.readyReject?.(error);
559
+ this.readyReject = null;
560
+ this.readyResolve = null;
561
+ this.readyPromise = null;
562
+ if (this.session?.accessToken) {
563
+ this.scheduleReconnect();
564
+ }
565
+ });
566
+ socket.on("error", (error) => {
567
+ this.lastError = error.message;
568
+ this.connectedIpcPath = null;
569
+ this.readyReject?.(error);
570
+ this.readyReject = null;
571
+ this.readyResolve = null;
572
+ this.readyPromise = null;
573
+ this.rejectPendingRequests(error);
574
+ });
575
+ await this.readyPromise;
576
+ }
577
+ scheduleReconnect() {
578
+ if (this.reconnectTimer) {
579
+ clearTimeout(this.reconnectTimer);
580
+ }
581
+ this.reconnectTimer = setTimeout(() => {
582
+ this.reconnectTimer = null;
583
+ this.ensureAuthenticated().catch((error) => {
584
+ this.lastError = error instanceof Error ? error.message : String(error);
585
+ });
586
+ }, 3000);
587
+ }
588
+ handleSocketData(chunk) {
589
+ this.readBuffer = Buffer.concat([this.readBuffer, chunk]);
590
+ while (this.readBuffer.length >= 8) {
591
+ const op = this.readBuffer.readInt32LE(0);
592
+ const length = this.readBuffer.readInt32LE(4);
593
+ if (length < 0) {
594
+ logger.warn("[discord-local] Discarding malformed IPC frame with negative payload length");
595
+ this.readBuffer = Buffer.alloc(0);
596
+ return;
597
+ }
598
+ if (this.readBuffer.length < 8 + length) {
599
+ return;
600
+ }
601
+ const body = this.readBuffer.subarray(8, 8 + length);
602
+ this.readBuffer = this.readBuffer.subarray(8 + length);
603
+ let payload;
604
+ try {
605
+ payload = JSON.parse(body.toString("utf8"));
606
+ } catch {
607
+ logger.warn("[discord-local] Discarding malformed IPC frame with invalid JSON payload");
608
+ continue;
609
+ }
610
+ this.handleRpcPayload(op, payload);
611
+ }
612
+ }
613
+ handleRpcPayload(op, payload) {
614
+ if (op === IPC_OP_PING) {
615
+ this.writeFrame(IPC_OP_PONG, payload);
616
+ return;
617
+ }
618
+ if (op === IPC_OP_CLOSE) {
619
+ this.lastError = "Discord local RPC closed the connection";
620
+ this.socket?.destroy();
621
+ return;
622
+ }
623
+ if (payload.nonce) {
624
+ const pending = this.pendingRequests.get(payload.nonce);
625
+ if (pending) {
626
+ this.pendingRequests.delete(payload.nonce);
627
+ if (payload.evt === "ERROR") {
628
+ pending.reject(new Error(describeRpcError(payload)));
629
+ } else {
630
+ pending.resolve(payload);
631
+ }
632
+ return;
633
+ }
634
+ }
635
+ if (payload.evt === "READY") {
636
+ this.connected = true;
637
+ this.readyResolve?.();
638
+ this.readyResolve = null;
639
+ this.readyReject = null;
640
+ this.readyPromise = null;
641
+ return;
642
+ }
643
+ if (payload.evt === "MESSAGE_CREATE") {
644
+ const data = payload.data;
645
+ const channelId = typeof data?.channel_id === "string" ? data.channel_id : undefined;
646
+ const message = data && typeof data.message === "object" ? data.message : payload.data;
647
+ if (channelId && message) {
648
+ this.ingestMessage(channelId, message);
649
+ }
650
+ return;
651
+ }
652
+ if (payload.evt === "NOTIFICATION_CREATE") {
653
+ const notification = payload.data;
654
+ const channelId = notification?.channel_id ?? (typeof notification?.message?.channel_id === "string" ? notification.message.channel_id : undefined);
655
+ const message = notification?.message ?? null;
656
+ if (channelId && message) {
657
+ this.ingestMessage(channelId, message);
658
+ }
659
+ }
660
+ }
661
+ writeFrame(op, payload) {
662
+ if (!this.socket) {
663
+ throw new Error("Discord local RPC socket is not connected");
664
+ }
665
+ const body = Buffer.from(JSON.stringify(payload), "utf8");
666
+ const header = Buffer.alloc(8);
667
+ header.writeInt32LE(op, 0);
668
+ header.writeInt32LE(body.length, 4);
669
+ this.socket.write(Buffer.concat([header, body]));
670
+ }
671
+ async sendRpcCommand(cmd, args = {}, evt) {
672
+ await this.ensureRpcConnection();
673
+ const nonce = crypto.randomUUID();
674
+ return await new Promise((resolve, reject) => {
675
+ const timeout = setTimeout(() => {
676
+ if (!this.pendingRequests.delete(nonce)) {
677
+ return;
678
+ }
679
+ reject(new Error(`Discord RPC command ${cmd} timed out`));
680
+ }, 20000);
681
+ this.pendingRequests.set(nonce, {
682
+ resolve: (value) => {
683
+ clearTimeout(timeout);
684
+ resolve(value);
685
+ },
686
+ reject: (error) => {
687
+ clearTimeout(timeout);
688
+ reject(error);
689
+ }
690
+ });
691
+ try {
692
+ this.writeFrame(IPC_OP_FRAME, {
693
+ cmd,
694
+ args,
695
+ ...evt ? { evt } : {},
696
+ nonce
697
+ });
698
+ } catch (error) {
699
+ clearTimeout(timeout);
700
+ this.pendingRequests.delete(nonce);
701
+ reject(error instanceof Error ? error : new Error(String(error)));
702
+ }
703
+ });
704
+ }
705
+ rejectPendingRequests(error) {
706
+ for (const pending of this.pendingRequests.values()) {
707
+ pending.reject(error);
708
+ }
709
+ this.pendingRequests.clear();
710
+ }
711
+ async ingestMessage(channelId, message) {
712
+ if (!message.id) {
713
+ return;
714
+ }
715
+ if (message.author?.id && message.author.id === this.currentUser?.id) {
716
+ return;
717
+ }
718
+ const memoryId = messageIdFor(this.runtime, channelId, message.id);
719
+ const existing = await this.runtime.getMemoryById(memoryId);
720
+ if (existing) {
721
+ return;
722
+ }
723
+ const channel = await this.getChannel(channelId);
724
+ const guildId = channel?.guild_id ?? message.guild_id ?? null;
725
+ const guild = guildId ? this.guildCache.get(guildId) ?? null : null;
726
+ const serverKey = guildId ?? `dm:${channelId}`;
727
+ const worldId = worldIdFor(this.runtime, serverKey);
728
+ const roomId = roomIdFor(this.runtime, channelId);
729
+ const entityId = entityIdFor(message.author?.id ?? channelId);
730
+ const roomType = roomTypeForChannel(channel?.type);
731
+ const roomName = channel?.name?.trim() || message.author?.global_name || message.author?.username || `Discord ${channelId}`;
732
+ await this.runtime.ensureConnection({
733
+ entityId,
734
+ roomId,
735
+ roomName,
736
+ worldId,
737
+ worldName: guild?.name ?? "Discord Direct Messages",
738
+ userName: message.author?.username ?? undefined,
739
+ userId: message.author?.id,
740
+ name: message.author?.global_name ?? message.author?.username ?? undefined,
741
+ source: DISCORD_LOCAL_SERVICE_NAME,
742
+ type: roomType,
743
+ channelId,
744
+ messageServerId: stringToUuid(`discord-local-server:${serverKey}`),
745
+ metadata: {
746
+ discordChannelId: channelId,
747
+ ...guildId ? { discordServerId: guildId } : {}
748
+ }
749
+ });
750
+ const attachments = (message.attachments ?? []).flatMap((attachment) => {
751
+ const url = attachment.url?.trim();
752
+ if (!url) {
753
+ return [];
754
+ }
755
+ return [
756
+ {
757
+ id: attachment.id,
758
+ url,
759
+ title: attachment.filename,
760
+ source: DISCORD_LOCAL_SERVICE_NAME,
761
+ description: attachment.description ?? undefined,
762
+ contentType: contentTypeForMime(attachment.content_type)
763
+ }
764
+ ];
765
+ });
766
+ const replyReference = message.referenced_message?.id ?? message.message_reference?.message_id ?? undefined;
767
+ const replyChannelId = message.referenced_message?.channel_id ?? message.message_reference?.channel_id ?? channelId;
768
+ const inReplyTo = typeof replyReference === "string" && replyReference.length > 0 ? messageIdFor(this.runtime, replyChannelId, replyReference) : undefined;
769
+ const memory = createMessageMemory({
770
+ id: memoryId,
771
+ agentId: this.runtime.agentId,
772
+ entityId,
773
+ roomId,
774
+ content: {
775
+ text: message.content ?? "",
776
+ source: DISCORD_LOCAL_SERVICE_NAME,
777
+ ...attachments.length > 0 ? { attachments } : {},
778
+ ...inReplyTo ? { inReplyTo } : {}
779
+ }
780
+ });
781
+ memory.createdAt = message.timestamp ? Date.parse(message.timestamp) : Date.now();
782
+ memory.metadata = {
783
+ ...memory.metadata,
784
+ source: DISCORD_LOCAL_SERVICE_NAME,
785
+ provider: "discord",
786
+ timestamp: memory.createdAt,
787
+ entityName: message.author?.global_name ?? message.author?.username ?? roomName,
788
+ entityUserName: message.author?.username ?? undefined,
789
+ entityAvatarUrl: buildDiscordAvatarUrl(message.author),
790
+ fromId: message.author?.id ?? undefined,
791
+ sourceId: entityId,
792
+ sender: {
793
+ id: message.author?.id ?? undefined,
794
+ name: message.author?.global_name ?? message.author?.username ?? roomName,
795
+ username: message.author?.username ?? undefined
796
+ },
797
+ [DISCORD_LOCAL_SERVICE_NAME]: {
798
+ id: message.author?.id ?? undefined,
799
+ userId: message.author?.id ?? undefined,
800
+ username: message.author?.username ?? undefined,
801
+ userName: message.author?.username ?? undefined,
802
+ name: message.author?.global_name ?? message.author?.username ?? roomName,
803
+ messageId: message.id,
804
+ channelId,
805
+ guildId: guildId ?? undefined
806
+ },
807
+ discord: {
808
+ id: message.author?.id ?? undefined,
809
+ userId: message.author?.id ?? undefined,
810
+ username: message.author?.username ?? undefined,
811
+ userName: message.author?.username ?? undefined,
812
+ name: message.author?.global_name ?? message.author?.username ?? roomName,
813
+ messageId: message.id,
814
+ channelId,
815
+ guildId: guildId ?? undefined
816
+ },
817
+ discordChannelId: channelId,
818
+ discordMessageId: message.id,
819
+ ...guildId ? { discordServerId: guildId } : {}
820
+ };
821
+ await this.runtime.createMemory(memory, "messages");
822
+ }
823
+ async sendUiMessage(channelId, guildId, text) {
824
+ const config = this.requireConfig();
825
+ if (process.platform !== "darwin") {
826
+ throw new Error("Discord local send automation currently supports macOS only");
827
+ }
828
+ await openDiscordTarget(channelId, guildId);
829
+ const script = buildDiscordSendScript(text, config.sendDelayMs / 1000);
830
+ await execFileAsync("/usr/bin/osascript", ["-e", script]);
831
+ }
832
+ }
833
+ function isConnectorSetupService(service) {
834
+ if (!service || typeof service !== "object") {
835
+ return false;
836
+ }
837
+ const candidate = service;
838
+ return typeof candidate.getConfig === "function" && typeof candidate.updateConfig === "function" && typeof candidate.registerEscalationChannel === "function" && typeof candidate.setOwnerContact === "function";
839
+ }
840
+ function getSetupService(runtime) {
841
+ const service = runtime.getService("connector-setup");
842
+ return isConnectorSetupService(service) ? service : null;
843
+ }
844
+ function resolveDiscordLocalService(runtime) {
845
+ const service = runtime.getService(DISCORD_LOCAL_SERVICE_NAME);
846
+ return service instanceof DiscordLocalService ? service : null;
847
+ }
848
+ function getConnectorConfig(setupService) {
849
+ const config = setupService.getConfig();
850
+ const connectors = config.connectors ?? config.channels ?? {};
851
+ const current = connectors.discordLocal;
852
+ if (current && typeof current === "object" && !Array.isArray(current)) {
853
+ return current;
854
+ }
855
+ return {};
856
+ }
857
+ async function handleDiscordLocalStatus(_req, res, runtime) {
858
+ const service = resolveDiscordLocalService(runtime);
859
+ res.status(200).json(service ? service.getStatus() : {
860
+ available: false,
861
+ connected: false,
862
+ authenticated: false,
863
+ currentUser: null,
864
+ subscribedChannelIds: [],
865
+ configuredChannelIds: [],
866
+ scopes: [],
867
+ lastError: "discord-local service not registered",
868
+ ipcPath: null,
869
+ reason: "discord-local service not registered"
870
+ });
871
+ }
872
+ async function handleDiscordLocalAuthorize(_req, res, runtime) {
873
+ const service = resolveDiscordLocalService(runtime);
874
+ if (!service) {
875
+ res.status(503).json({ error: "discord-local service not registered" });
876
+ return;
877
+ }
878
+ try {
879
+ res.status(200).json(await service.authorize());
880
+ } catch (error) {
881
+ res.status(500).json({
882
+ error: `failed to authorize discord-local: ${error instanceof Error ? error.message : String(error)}`
883
+ });
884
+ }
885
+ }
886
+ async function handleDiscordLocalDisconnect(_req, res, runtime) {
887
+ const service = resolveDiscordLocalService(runtime);
888
+ if (!service) {
889
+ res.status(503).json({ error: "discord-local service not registered" });
890
+ return;
891
+ }
892
+ try {
893
+ await service.disconnectSession();
894
+ res.status(200).json({ ok: true });
895
+ } catch (error) {
896
+ res.status(500).json({
897
+ error: `failed to disconnect discord-local: ${error instanceof Error ? error.message : String(error)}`
898
+ });
899
+ }
900
+ }
901
+ async function handleDiscordLocalGuilds(_req, res, runtime) {
902
+ const service = resolveDiscordLocalService(runtime);
903
+ if (!service) {
904
+ res.status(503).json({ error: "discord-local service not registered" });
905
+ return;
906
+ }
907
+ try {
908
+ const guilds = await service.listGuilds();
909
+ res.status(200).json({ guilds, count: guilds.length });
910
+ } catch (error) {
911
+ res.status(500).json({
912
+ error: `failed to list discord-local guilds: ${error instanceof Error ? error.message : String(error)}`
913
+ });
914
+ }
915
+ }
916
+ async function handleDiscordLocalChannels(req, res, runtime) {
917
+ const service = resolveDiscordLocalService(runtime);
918
+ if (!service) {
919
+ res.status(503).json({ error: "discord-local service not registered" });
920
+ return;
921
+ }
922
+ const url = new URL(req.url ?? "/api/discord-local/channels", "http://localhost");
923
+ const guildId = url.searchParams.get("guildId")?.trim() ?? "";
924
+ if (!guildId) {
925
+ res.status(400).json({ error: "guildId is required" });
926
+ return;
927
+ }
928
+ try {
929
+ const channels = await service.listChannels(guildId);
930
+ res.status(200).json({ channels, count: channels.length });
931
+ } catch (error) {
932
+ res.status(500).json({
933
+ error: `failed to list discord-local channels: ${error instanceof Error ? error.message : String(error)}`
934
+ });
935
+ }
936
+ }
937
+ async function handleDiscordLocalSubscriptions(req, res, runtime) {
938
+ const service = resolveDiscordLocalService(runtime);
939
+ if (!service) {
940
+ res.status(503).json({ error: "discord-local service not registered" });
941
+ return;
942
+ }
943
+ const body = req.body;
944
+ if (!body) {
945
+ res.status(400).json({ error: "request body is required" });
946
+ return;
947
+ }
948
+ const rawChannelIds = body.channelIds;
949
+ const channelIds = Array.isArray(rawChannelIds) ? Array.from(new Set(rawChannelIds.map((entry) => typeof entry === "string" ? entry.trim() : "").filter((entry) => entry.length > 0))) : [];
950
+ try {
951
+ const subscribedChannelIds = await service.subscribeChannelMessages(channelIds);
952
+ const setupService = getSetupService(runtime);
953
+ if (setupService) {
954
+ const connectorConfig = getConnectorConfig(setupService);
955
+ setupService.updateConfig((config) => {
956
+ if (!config.connectors) {
957
+ config.connectors = {};
958
+ }
959
+ config.connectors.discordLocal = {
960
+ ...connectorConfig,
961
+ enabled: connectorConfig.enabled !== false,
962
+ messageChannelIds: subscribedChannelIds
963
+ };
964
+ });
965
+ if (subscribedChannelIds.length > 0) {
966
+ setupService.setOwnerContact({
967
+ source: "discord",
968
+ channelId: subscribedChannelIds[0]
969
+ });
970
+ setupService.registerEscalationChannel("discord");
971
+ }
972
+ }
973
+ res.status(200).json({ subscribedChannelIds });
974
+ } catch (error) {
975
+ res.status(500).json({
976
+ error: `failed to update discord-local subscriptions: ${error instanceof Error ? error.message : String(error)}`
977
+ });
978
+ }
979
+ }
980
+ var discordLocalSetupRoutes = [
981
+ {
982
+ type: "GET",
983
+ path: "/api/discord-local/status",
984
+ handler: handleDiscordLocalStatus,
985
+ rawPath: true
986
+ },
987
+ {
988
+ type: "POST",
989
+ path: "/api/discord-local/authorize",
990
+ handler: handleDiscordLocalAuthorize,
991
+ rawPath: true
992
+ },
993
+ {
994
+ type: "POST",
995
+ path: "/api/discord-local/disconnect",
996
+ handler: handleDiscordLocalDisconnect,
997
+ rawPath: true
998
+ },
999
+ {
1000
+ type: "GET",
1001
+ path: "/api/discord-local/guilds",
1002
+ handler: handleDiscordLocalGuilds,
1003
+ rawPath: true
1004
+ },
1005
+ {
1006
+ type: "GET",
1007
+ path: "/api/discord-local/channels",
1008
+ handler: handleDiscordLocalChannels,
1009
+ rawPath: true
1010
+ },
1011
+ {
1012
+ type: "POST",
1013
+ path: "/api/discord-local/subscriptions",
1014
+ handler: handleDiscordLocalSubscriptions,
1015
+ rawPath: true
1016
+ }
1017
+ ];
1018
+ var discordLocalPlugin = {
1019
+ name: DISCORD_LOCAL_PLUGIN_NAME,
1020
+ description: "Local Discord desktop integration for Eliza via Discord RPC and macOS UI automation",
1021
+ services: [DiscordLocalService],
1022
+ routes: discordLocalSetupRoutes
1023
+ };
1024
+ var src_default = discordLocalPlugin;
1025
+ export {
1026
+ src_default as default,
1027
+ DiscordLocalService,
1028
+ DISCORD_LOCAL_SERVICE_NAME,
1029
+ DISCORD_LOCAL_PLUGIN_NAME
1030
+ };
1031
+
1032
+ //# debugId=675CB555791E257564756E2164756E21
1033
+ //# sourceMappingURL=index.js.map