@thesammykins/tether 1.0.0
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/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/bin/tether.ts +1010 -0
- package/index.ts +32 -0
- package/package.json +64 -0
- package/src/adapters/claude.ts +107 -0
- package/src/adapters/codex.ts +77 -0
- package/src/adapters/opencode.ts +83 -0
- package/src/adapters/registry.ts +23 -0
- package/src/adapters/types.ts +17 -0
- package/src/api.ts +494 -0
- package/src/bot.ts +653 -0
- package/src/db.ts +123 -0
- package/src/discord.ts +80 -0
- package/src/features/ack.ts +10 -0
- package/src/features/brb.ts +79 -0
- package/src/features/channel-context.ts +23 -0
- package/src/features/pause-resume.ts +86 -0
- package/src/features/session-limits.ts +48 -0
- package/src/features/thread-naming.ts +33 -0
- package/src/middleware/allowlist.ts +64 -0
- package/src/middleware/rate-limiter.ts +46 -0
- package/src/queue.ts +43 -0
- package/src/spawner.ts +110 -0
- package/src/worker.ts +97 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP API Server - Discord primitives for external tools
|
|
3
|
+
*
|
|
4
|
+
* Provides HTTP endpoints for sending messages, embeds, files, buttons,
|
|
5
|
+
* and managing threads. Useful for scripts, automation, and Claude skills.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Client, TextChannel, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
|
|
9
|
+
|
|
10
|
+
const log = (msg: string) => process.stdout.write(`[api] ${msg}\n`);
|
|
11
|
+
|
|
12
|
+
// Button handler registry for dynamic button responses
|
|
13
|
+
type ButtonHandler = {
|
|
14
|
+
type: 'inline';
|
|
15
|
+
content: string;
|
|
16
|
+
ephemeral?: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'webhook';
|
|
19
|
+
url: string;
|
|
20
|
+
data?: Record<string, unknown>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const buttonHandlers = new Map<string, ButtonHandler>();
|
|
24
|
+
|
|
25
|
+
// Question response store — maps requestId to response
|
|
26
|
+
export const questionResponses = new Map<string, { answer: string; optionIndex: number } | null>();
|
|
27
|
+
|
|
28
|
+
// Track which threads are waiting for a typed answer
|
|
29
|
+
export const pendingTypedAnswers = new Map<string, string>(); // threadId → requestId
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Start the HTTP API server
|
|
33
|
+
*/
|
|
34
|
+
export function startApiServer(client: Client, port: number = 2643) {
|
|
35
|
+
const server = Bun.serve({
|
|
36
|
+
port,
|
|
37
|
+
async fetch(req) {
|
|
38
|
+
const url = new URL(req.url);
|
|
39
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
40
|
+
|
|
41
|
+
// Health check
|
|
42
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
43
|
+
return new Response(JSON.stringify({
|
|
44
|
+
status: 'ok',
|
|
45
|
+
connected: client.isReady(),
|
|
46
|
+
user: client.user?.tag || null,
|
|
47
|
+
}), { headers });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Send message to thread/channel
|
|
51
|
+
if (url.pathname === '/command' && req.method === 'POST') {
|
|
52
|
+
try {
|
|
53
|
+
const body = await req.json() as {
|
|
54
|
+
command: string;
|
|
55
|
+
args: Record<string, unknown>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const result = await handleCommand(client, body.command, body.args);
|
|
59
|
+
return new Response(JSON.stringify(result), { headers });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
log(`Command error: ${error}`);
|
|
62
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
63
|
+
status: 500,
|
|
64
|
+
headers,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Send file attachment
|
|
70
|
+
if (url.pathname === '/send-with-file' && req.method === 'POST') {
|
|
71
|
+
try {
|
|
72
|
+
const body = await req.json() as {
|
|
73
|
+
channelId: string;
|
|
74
|
+
fileName: string;
|
|
75
|
+
fileContent: string;
|
|
76
|
+
content?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const channel = await client.channels.fetch(body.channelId);
|
|
80
|
+
if (!channel?.isTextBased()) {
|
|
81
|
+
return new Response(JSON.stringify({ error: 'Invalid channel' }), {
|
|
82
|
+
status: 400,
|
|
83
|
+
headers,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const buffer = Buffer.from(body.fileContent, 'utf-8');
|
|
88
|
+
const message = await (channel as TextChannel).send({
|
|
89
|
+
content: body.content || undefined,
|
|
90
|
+
files: [{
|
|
91
|
+
attachment: buffer,
|
|
92
|
+
name: body.fileName,
|
|
93
|
+
}],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return new Response(JSON.stringify({
|
|
97
|
+
success: true,
|
|
98
|
+
messageId: message.id,
|
|
99
|
+
}), { headers });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
log(`Send file error: ${error}`);
|
|
102
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
103
|
+
status: 500,
|
|
104
|
+
headers,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Send file via DM to a user
|
|
110
|
+
if (url.pathname === '/send-dm-file' && req.method === 'POST') {
|
|
111
|
+
try {
|
|
112
|
+
const body = await req.json() as {
|
|
113
|
+
userId: string;
|
|
114
|
+
fileName: string;
|
|
115
|
+
fileContent: string;
|
|
116
|
+
content?: string;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const user = await client.users.fetch(body.userId);
|
|
120
|
+
const buffer = Buffer.from(body.fileContent, 'utf-8');
|
|
121
|
+
const message = await user.send({
|
|
122
|
+
content: body.content || undefined,
|
|
123
|
+
files: [{
|
|
124
|
+
attachment: buffer,
|
|
125
|
+
name: body.fileName,
|
|
126
|
+
}],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return new Response(JSON.stringify({
|
|
130
|
+
success: true,
|
|
131
|
+
messageId: message.id,
|
|
132
|
+
channelId: message.channelId,
|
|
133
|
+
}), { headers });
|
|
134
|
+
} catch (error) {
|
|
135
|
+
log(`Send DM file error: ${error}`);
|
|
136
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
137
|
+
status: 500,
|
|
138
|
+
headers,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Send message with buttons
|
|
144
|
+
if (url.pathname === '/send-with-buttons' && req.method === 'POST') {
|
|
145
|
+
try {
|
|
146
|
+
const body = await req.json() as {
|
|
147
|
+
channelId: string;
|
|
148
|
+
content?: string;
|
|
149
|
+
embeds?: Array<{
|
|
150
|
+
title?: string;
|
|
151
|
+
description?: string;
|
|
152
|
+
color?: number;
|
|
153
|
+
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
|
154
|
+
footer?: { text: string };
|
|
155
|
+
}>;
|
|
156
|
+
buttons: Array<{
|
|
157
|
+
label: string;
|
|
158
|
+
customId: string;
|
|
159
|
+
style: 'primary' | 'secondary' | 'success' | 'danger';
|
|
160
|
+
handler?: ButtonHandler;
|
|
161
|
+
}>;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const channel = await client.channels.fetch(body.channelId);
|
|
165
|
+
if (!channel?.isTextBased()) {
|
|
166
|
+
return new Response(JSON.stringify({ error: 'Invalid channel' }), {
|
|
167
|
+
status: 400,
|
|
168
|
+
headers,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Build embed if provided
|
|
173
|
+
const embeds = body.embeds?.map(e => {
|
|
174
|
+
const embed = new EmbedBuilder();
|
|
175
|
+
if (e.title) embed.setTitle(e.title);
|
|
176
|
+
if (e.description) embed.setDescription(e.description);
|
|
177
|
+
if (e.color) embed.setColor(e.color);
|
|
178
|
+
if (e.fields) embed.addFields(e.fields);
|
|
179
|
+
if (e.footer) embed.setFooter(e.footer);
|
|
180
|
+
return embed;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Build button row
|
|
184
|
+
const styleMap: Record<string, ButtonStyle> = {
|
|
185
|
+
primary: ButtonStyle.Primary,
|
|
186
|
+
secondary: ButtonStyle.Secondary,
|
|
187
|
+
success: ButtonStyle.Success,
|
|
188
|
+
danger: ButtonStyle.Danger,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const buttons = body.buttons.map(b => {
|
|
192
|
+
// Register handler if provided
|
|
193
|
+
if (b.handler) {
|
|
194
|
+
buttonHandlers.set(b.customId, b.handler);
|
|
195
|
+
log(`Registered button handler: ${b.customId}`);
|
|
196
|
+
} else {
|
|
197
|
+
log(`No handler for button: ${b.customId}`);
|
|
198
|
+
}
|
|
199
|
+
return new ButtonBuilder()
|
|
200
|
+
.setCustomId(b.customId)
|
|
201
|
+
.setLabel(b.label)
|
|
202
|
+
.setStyle(styleMap[b.style] || ButtonStyle.Primary);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buttons);
|
|
206
|
+
|
|
207
|
+
const message = await (channel as TextChannel).send({
|
|
208
|
+
content: body.content || undefined,
|
|
209
|
+
embeds: embeds || undefined,
|
|
210
|
+
components: [row],
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return new Response(JSON.stringify({
|
|
214
|
+
success: true,
|
|
215
|
+
messageId: message.id,
|
|
216
|
+
}), { headers });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
log(`Send buttons error: ${error}`);
|
|
219
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
220
|
+
status: 500,
|
|
221
|
+
headers,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Question response webhook - POST /question-response/:requestId
|
|
227
|
+
if (url.pathname.startsWith('/question-response/') && req.method === 'POST') {
|
|
228
|
+
try {
|
|
229
|
+
const requestId = url.pathname.split('/question-response/')[1];
|
|
230
|
+
if (!requestId) {
|
|
231
|
+
return new Response(JSON.stringify({ error: 'Missing requestId' }), {
|
|
232
|
+
status: 400,
|
|
233
|
+
headers,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const body = await req.json() as {
|
|
238
|
+
customId: string;
|
|
239
|
+
userId: string;
|
|
240
|
+
channelId: string;
|
|
241
|
+
data: {
|
|
242
|
+
option: string;
|
|
243
|
+
optionIndex: number;
|
|
244
|
+
threadId?: string;
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Store the response
|
|
249
|
+
questionResponses.set(requestId, {
|
|
250
|
+
answer: body.data.option,
|
|
251
|
+
optionIndex: body.data.optionIndex,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// If user clicked "Type answer", track it
|
|
255
|
+
if (body.data.option === '__type__' && body.data.threadId) {
|
|
256
|
+
pendingTypedAnswers.set(body.data.threadId, requestId);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Auto-cleanup after 10 minutes
|
|
260
|
+
setTimeout(() => questionResponses.delete(requestId), 600_000);
|
|
261
|
+
|
|
262
|
+
log(`Question response stored: ${requestId} → ${body.data.option}`);
|
|
263
|
+
|
|
264
|
+
return new Response(JSON.stringify({ success: true }), { headers });
|
|
265
|
+
} catch (error) {
|
|
266
|
+
log(`Question response error: ${error}`);
|
|
267
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
268
|
+
status: 500,
|
|
269
|
+
headers,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Get question response - GET /question-response/:requestId
|
|
275
|
+
if (url.pathname.startsWith('/question-response/') && req.method === 'GET') {
|
|
276
|
+
const requestId = url.pathname.split('/question-response/')[1];
|
|
277
|
+
if (!requestId) {
|
|
278
|
+
return new Response(JSON.stringify({ error: 'Missing requestId' }), {
|
|
279
|
+
status: 400,
|
|
280
|
+
headers,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!questionResponses.has(requestId)) {
|
|
285
|
+
return new Response(JSON.stringify({ error: 'Unknown requestId' }), {
|
|
286
|
+
status: 404,
|
|
287
|
+
headers,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const response = questionResponses.get(requestId);
|
|
292
|
+
if (response === null || response === undefined) {
|
|
293
|
+
// Registered but not yet answered (or not found, but we checked has() above)
|
|
294
|
+
return new Response(JSON.stringify({ answered: false }), { headers });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Answered
|
|
298
|
+
return new Response(JSON.stringify({
|
|
299
|
+
answered: true,
|
|
300
|
+
answer: response.answer,
|
|
301
|
+
optionIndex: response.optionIndex,
|
|
302
|
+
}), { headers });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 404 for unknown routes
|
|
306
|
+
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
307
|
+
status: 404,
|
|
308
|
+
headers,
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
log(`HTTP API server listening on port ${port}`);
|
|
314
|
+
return server;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Handle a command from the /command endpoint
|
|
319
|
+
*/
|
|
320
|
+
async function handleCommand(
|
|
321
|
+
client: Client,
|
|
322
|
+
command: string,
|
|
323
|
+
args: Record<string, unknown>
|
|
324
|
+
): Promise<Record<string, unknown>> {
|
|
325
|
+
switch (command) {
|
|
326
|
+
case 'send-to-thread': {
|
|
327
|
+
const threadId = args.thread as string;
|
|
328
|
+
const message = args.message as string | undefined;
|
|
329
|
+
const embeds = args.embeds as Array<{
|
|
330
|
+
title?: string;
|
|
331
|
+
description?: string;
|
|
332
|
+
color?: number;
|
|
333
|
+
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
|
334
|
+
footer?: { text: string };
|
|
335
|
+
}> | undefined;
|
|
336
|
+
|
|
337
|
+
const channel = await client.channels.fetch(threadId);
|
|
338
|
+
if (!channel?.isTextBased()) {
|
|
339
|
+
throw new Error('Invalid thread/channel');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Build embeds if provided
|
|
343
|
+
const discordEmbeds = embeds?.map(e => {
|
|
344
|
+
const embed = new EmbedBuilder();
|
|
345
|
+
if (e.title) embed.setTitle(e.title);
|
|
346
|
+
if (e.description) embed.setDescription(e.description);
|
|
347
|
+
if (e.color) embed.setColor(e.color);
|
|
348
|
+
if (e.fields) embed.addFields(e.fields);
|
|
349
|
+
if (e.footer) embed.setFooter(e.footer);
|
|
350
|
+
return embed;
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const sent = await (channel as TextChannel).send({
|
|
354
|
+
content: message || undefined,
|
|
355
|
+
embeds: discordEmbeds || undefined,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return { success: true, messageId: sent.id };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
case 'start-typing': {
|
|
362
|
+
const channelId = args.channel as string;
|
|
363
|
+
const channel = await client.channels.fetch(channelId);
|
|
364
|
+
if (!channel?.isTextBased()) {
|
|
365
|
+
throw new Error('Invalid channel');
|
|
366
|
+
}
|
|
367
|
+
await (channel as TextChannel).sendTyping();
|
|
368
|
+
return { success: true };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
case 'edit-message': {
|
|
372
|
+
const channelId = args.channel as string;
|
|
373
|
+
const messageId = args.message as string;
|
|
374
|
+
const content = args.content as string;
|
|
375
|
+
|
|
376
|
+
const channel = await client.channels.fetch(channelId);
|
|
377
|
+
if (!channel?.isTextBased()) {
|
|
378
|
+
throw new Error('Invalid channel');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const message = await (channel as TextChannel).messages.fetch(messageId);
|
|
382
|
+
await message.edit(content);
|
|
383
|
+
return { success: true };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
case 'delete-message': {
|
|
387
|
+
const channelId = args.channel as string;
|
|
388
|
+
const messageId = args.message as string;
|
|
389
|
+
|
|
390
|
+
const channel = await client.channels.fetch(channelId);
|
|
391
|
+
if (!channel?.isTextBased()) {
|
|
392
|
+
throw new Error('Invalid channel');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const message = await (channel as TextChannel).messages.fetch(messageId);
|
|
396
|
+
await message.delete();
|
|
397
|
+
return { success: true };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
case 'rename-thread': {
|
|
401
|
+
const threadId = args.thread as string;
|
|
402
|
+
const name = args.name as string;
|
|
403
|
+
|
|
404
|
+
const channel = await client.channels.fetch(threadId);
|
|
405
|
+
if (!channel?.isThread()) {
|
|
406
|
+
throw new Error('Invalid thread');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
await channel.setName(name);
|
|
410
|
+
return { success: true };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
case 'reply-to-message': {
|
|
414
|
+
const channelId = args.channel as string;
|
|
415
|
+
const messageId = args.message as string;
|
|
416
|
+
const content = args.content as string;
|
|
417
|
+
|
|
418
|
+
const channel = await client.channels.fetch(channelId);
|
|
419
|
+
if (!channel?.isTextBased()) {
|
|
420
|
+
throw new Error('Invalid channel');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const targetMessage = await (channel as TextChannel).messages.fetch(messageId);
|
|
424
|
+
const sent = await targetMessage.reply(content);
|
|
425
|
+
return { success: true, messageId: sent.id };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
case 'create-thread': {
|
|
429
|
+
const channelId = args.channel as string;
|
|
430
|
+
const messageId = args.message as string;
|
|
431
|
+
const name = args.name as string;
|
|
432
|
+
|
|
433
|
+
const channel = await client.channels.fetch(channelId);
|
|
434
|
+
if (!channel?.isTextBased()) {
|
|
435
|
+
throw new Error('Invalid channel');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const message = await (channel as TextChannel).messages.fetch(messageId);
|
|
439
|
+
const thread = await message.startThread({ name });
|
|
440
|
+
return { success: true, threadId: thread.id };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
case 'add-reaction': {
|
|
444
|
+
const channelId = args.channel as string;
|
|
445
|
+
const messageId = args.message as string;
|
|
446
|
+
const emoji = args.emoji as string;
|
|
447
|
+
|
|
448
|
+
const channel = await client.channels.fetch(channelId);
|
|
449
|
+
if (!channel?.isTextBased()) {
|
|
450
|
+
throw new Error('Invalid channel');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const message = await (channel as TextChannel).messages.fetch(messageId);
|
|
454
|
+
await message.react(emoji);
|
|
455
|
+
return { success: true };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
case 'send-dm': {
|
|
459
|
+
const userId = args.userId as string;
|
|
460
|
+
const message = args.message as string | undefined;
|
|
461
|
+
const embeds = args.embeds as Array<{
|
|
462
|
+
title?: string;
|
|
463
|
+
description?: string;
|
|
464
|
+
color?: number;
|
|
465
|
+
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
|
466
|
+
footer?: { text: string };
|
|
467
|
+
}> | undefined;
|
|
468
|
+
|
|
469
|
+
if (!userId) throw new Error('userId is required');
|
|
470
|
+
if (!message && !embeds?.length) throw new Error('message or embeds required');
|
|
471
|
+
|
|
472
|
+
const user = await client.users.fetch(userId);
|
|
473
|
+
const discordEmbeds = embeds?.map(e => {
|
|
474
|
+
const embed = new EmbedBuilder();
|
|
475
|
+
if (e.title) embed.setTitle(e.title);
|
|
476
|
+
if (e.description) embed.setDescription(e.description);
|
|
477
|
+
if (e.color) embed.setColor(e.color);
|
|
478
|
+
if (e.fields) embed.addFields(e.fields);
|
|
479
|
+
if (e.footer) embed.setFooter(e.footer);
|
|
480
|
+
return embed;
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const sent = await user.send({
|
|
484
|
+
content: message || undefined,
|
|
485
|
+
embeds: discordEmbeds || undefined,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
return { success: true, messageId: sent.id, channelId: sent.channelId };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
default:
|
|
492
|
+
throw new Error(`Unknown command: ${command}`);
|
|
493
|
+
}
|
|
494
|
+
}
|