@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/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
+ }