cord-bot 1.0.2 → 1.0.5
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/.github/workflows/publish.yml +32 -0
- package/README.md +59 -58
- package/bin/cord.ts +469 -17
- package/package.json +6 -1
- package/skills/cord/HTTP-API.md +273 -0
- package/skills/cord/SKILL.md +304 -0
- package/src/api.ts +339 -0
- package/src/bot.ts +46 -1
package/src/api.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
/**
|
|
26
|
+
* Start the HTTP API server
|
|
27
|
+
*/
|
|
28
|
+
export function startApiServer(client: Client, port: number = 2643) {
|
|
29
|
+
const server = Bun.serve({
|
|
30
|
+
port,
|
|
31
|
+
async fetch(req) {
|
|
32
|
+
const url = new URL(req.url);
|
|
33
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
34
|
+
|
|
35
|
+
// Health check
|
|
36
|
+
if (url.pathname === '/health' && req.method === 'GET') {
|
|
37
|
+
return new Response(JSON.stringify({
|
|
38
|
+
status: 'ok',
|
|
39
|
+
connected: client.isReady(),
|
|
40
|
+
user: client.user?.tag || null,
|
|
41
|
+
}), { headers });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Send message to thread/channel
|
|
45
|
+
if (url.pathname === '/command' && req.method === 'POST') {
|
|
46
|
+
try {
|
|
47
|
+
const body = await req.json() as {
|
|
48
|
+
command: string;
|
|
49
|
+
args: Record<string, unknown>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const result = await handleCommand(client, body.command, body.args);
|
|
53
|
+
return new Response(JSON.stringify(result), { headers });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
log(`Command error: ${error}`);
|
|
56
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
57
|
+
status: 500,
|
|
58
|
+
headers,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Send file attachment
|
|
64
|
+
if (url.pathname === '/send-with-file' && req.method === 'POST') {
|
|
65
|
+
try {
|
|
66
|
+
const body = await req.json() as {
|
|
67
|
+
channelId: string;
|
|
68
|
+
fileName: string;
|
|
69
|
+
fileContent: string;
|
|
70
|
+
content?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const channel = await client.channels.fetch(body.channelId);
|
|
74
|
+
if (!channel?.isTextBased()) {
|
|
75
|
+
return new Response(JSON.stringify({ error: 'Invalid channel' }), {
|
|
76
|
+
status: 400,
|
|
77
|
+
headers,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const buffer = Buffer.from(body.fileContent, 'utf-8');
|
|
82
|
+
const message = await (channel as TextChannel).send({
|
|
83
|
+
content: body.content || undefined,
|
|
84
|
+
files: [{
|
|
85
|
+
attachment: buffer,
|
|
86
|
+
name: body.fileName,
|
|
87
|
+
}],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return new Response(JSON.stringify({
|
|
91
|
+
success: true,
|
|
92
|
+
messageId: message.id,
|
|
93
|
+
}), { headers });
|
|
94
|
+
} catch (error) {
|
|
95
|
+
log(`Send file error: ${error}`);
|
|
96
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
97
|
+
status: 500,
|
|
98
|
+
headers,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Send message with buttons
|
|
104
|
+
if (url.pathname === '/send-with-buttons' && req.method === 'POST') {
|
|
105
|
+
try {
|
|
106
|
+
const body = await req.json() as {
|
|
107
|
+
channelId: string;
|
|
108
|
+
content?: string;
|
|
109
|
+
embeds?: Array<{
|
|
110
|
+
title?: string;
|
|
111
|
+
description?: string;
|
|
112
|
+
color?: number;
|
|
113
|
+
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
|
114
|
+
footer?: { text: string };
|
|
115
|
+
}>;
|
|
116
|
+
buttons: Array<{
|
|
117
|
+
label: string;
|
|
118
|
+
customId: string;
|
|
119
|
+
style: 'primary' | 'secondary' | 'success' | 'danger';
|
|
120
|
+
handler?: ButtonHandler;
|
|
121
|
+
}>;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const channel = await client.channels.fetch(body.channelId);
|
|
125
|
+
if (!channel?.isTextBased()) {
|
|
126
|
+
return new Response(JSON.stringify({ error: 'Invalid channel' }), {
|
|
127
|
+
status: 400,
|
|
128
|
+
headers,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build embed if provided
|
|
133
|
+
const embeds = body.embeds?.map(e => {
|
|
134
|
+
const embed = new EmbedBuilder();
|
|
135
|
+
if (e.title) embed.setTitle(e.title);
|
|
136
|
+
if (e.description) embed.setDescription(e.description);
|
|
137
|
+
if (e.color) embed.setColor(e.color);
|
|
138
|
+
if (e.fields) embed.addFields(e.fields);
|
|
139
|
+
if (e.footer) embed.setFooter(e.footer);
|
|
140
|
+
return embed;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Build button row
|
|
144
|
+
const styleMap: Record<string, ButtonStyle> = {
|
|
145
|
+
primary: ButtonStyle.Primary,
|
|
146
|
+
secondary: ButtonStyle.Secondary,
|
|
147
|
+
success: ButtonStyle.Success,
|
|
148
|
+
danger: ButtonStyle.Danger,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const buttons = body.buttons.map(b => {
|
|
152
|
+
// Register handler if provided
|
|
153
|
+
if (b.handler) {
|
|
154
|
+
buttonHandlers.set(b.customId, b.handler);
|
|
155
|
+
}
|
|
156
|
+
return new ButtonBuilder()
|
|
157
|
+
.setCustomId(b.customId)
|
|
158
|
+
.setLabel(b.label)
|
|
159
|
+
.setStyle(styleMap[b.style] || ButtonStyle.Primary);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(buttons);
|
|
163
|
+
|
|
164
|
+
const message = await (channel as TextChannel).send({
|
|
165
|
+
content: body.content || undefined,
|
|
166
|
+
embeds: embeds || undefined,
|
|
167
|
+
components: [row],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return new Response(JSON.stringify({
|
|
171
|
+
success: true,
|
|
172
|
+
messageId: message.id,
|
|
173
|
+
}), { headers });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
log(`Send buttons error: ${error}`);
|
|
176
|
+
return new Response(JSON.stringify({ error: String(error) }), {
|
|
177
|
+
status: 500,
|
|
178
|
+
headers,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 404 for unknown routes
|
|
184
|
+
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
185
|
+
status: 404,
|
|
186
|
+
headers,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
log(`HTTP API server listening on port ${port}`);
|
|
192
|
+
return server;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Handle a command from the /command endpoint
|
|
197
|
+
*/
|
|
198
|
+
async function handleCommand(
|
|
199
|
+
client: Client,
|
|
200
|
+
command: string,
|
|
201
|
+
args: Record<string, unknown>
|
|
202
|
+
): Promise<Record<string, unknown>> {
|
|
203
|
+
switch (command) {
|
|
204
|
+
case 'send-to-thread': {
|
|
205
|
+
const threadId = args.thread as string;
|
|
206
|
+
const message = args.message as string | undefined;
|
|
207
|
+
const embeds = args.embeds as Array<{
|
|
208
|
+
title?: string;
|
|
209
|
+
description?: string;
|
|
210
|
+
color?: number;
|
|
211
|
+
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
|
212
|
+
footer?: { text: string };
|
|
213
|
+
}> | undefined;
|
|
214
|
+
|
|
215
|
+
const channel = await client.channels.fetch(threadId);
|
|
216
|
+
if (!channel?.isTextBased()) {
|
|
217
|
+
throw new Error('Invalid thread/channel');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Build embeds if provided
|
|
221
|
+
const discordEmbeds = embeds?.map(e => {
|
|
222
|
+
const embed = new EmbedBuilder();
|
|
223
|
+
if (e.title) embed.setTitle(e.title);
|
|
224
|
+
if (e.description) embed.setDescription(e.description);
|
|
225
|
+
if (e.color) embed.setColor(e.color);
|
|
226
|
+
if (e.fields) embed.addFields(e.fields);
|
|
227
|
+
if (e.footer) embed.setFooter(e.footer);
|
|
228
|
+
return embed;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const sent = await (channel as TextChannel).send({
|
|
232
|
+
content: message || undefined,
|
|
233
|
+
embeds: discordEmbeds || undefined,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return { success: true, messageId: sent.id };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
case 'start-typing': {
|
|
240
|
+
const channelId = args.channel as string;
|
|
241
|
+
const channel = await client.channels.fetch(channelId);
|
|
242
|
+
if (!channel?.isTextBased()) {
|
|
243
|
+
throw new Error('Invalid channel');
|
|
244
|
+
}
|
|
245
|
+
await (channel as TextChannel).sendTyping();
|
|
246
|
+
return { success: true };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
case 'edit-message': {
|
|
250
|
+
const channelId = args.channel as string;
|
|
251
|
+
const messageId = args.message as string;
|
|
252
|
+
const content = args.content as string;
|
|
253
|
+
|
|
254
|
+
const channel = await client.channels.fetch(channelId);
|
|
255
|
+
if (!channel?.isTextBased()) {
|
|
256
|
+
throw new Error('Invalid channel');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const message = await (channel as TextChannel).messages.fetch(messageId);
|
|
260
|
+
await message.edit(content);
|
|
261
|
+
return { success: true };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
case 'delete-message': {
|
|
265
|
+
const channelId = args.channel as string;
|
|
266
|
+
const messageId = args.message as string;
|
|
267
|
+
|
|
268
|
+
const channel = await client.channels.fetch(channelId);
|
|
269
|
+
if (!channel?.isTextBased()) {
|
|
270
|
+
throw new Error('Invalid channel');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const message = await (channel as TextChannel).messages.fetch(messageId);
|
|
274
|
+
await message.delete();
|
|
275
|
+
return { success: true };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
case 'rename-thread': {
|
|
279
|
+
const threadId = args.thread as string;
|
|
280
|
+
const name = args.name as string;
|
|
281
|
+
|
|
282
|
+
const channel = await client.channels.fetch(threadId);
|
|
283
|
+
if (!channel?.isThread()) {
|
|
284
|
+
throw new Error('Invalid thread');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await channel.setName(name);
|
|
288
|
+
return { success: true };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case 'reply-to-message': {
|
|
292
|
+
const channelId = args.channel as string;
|
|
293
|
+
const messageId = args.message as string;
|
|
294
|
+
const content = args.content as string;
|
|
295
|
+
|
|
296
|
+
const channel = await client.channels.fetch(channelId);
|
|
297
|
+
if (!channel?.isTextBased()) {
|
|
298
|
+
throw new Error('Invalid channel');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const targetMessage = await (channel as TextChannel).messages.fetch(messageId);
|
|
302
|
+
const sent = await targetMessage.reply(content);
|
|
303
|
+
return { success: true, messageId: sent.id };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
case 'create-thread': {
|
|
307
|
+
const channelId = args.channel as string;
|
|
308
|
+
const messageId = args.message as string;
|
|
309
|
+
const name = args.name as string;
|
|
310
|
+
|
|
311
|
+
const channel = await client.channels.fetch(channelId);
|
|
312
|
+
if (!channel?.isTextBased()) {
|
|
313
|
+
throw new Error('Invalid channel');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const message = await (channel as TextChannel).messages.fetch(messageId);
|
|
317
|
+
const thread = await message.startThread({ name });
|
|
318
|
+
return { success: true, threadId: thread.id };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
case 'add-reaction': {
|
|
322
|
+
const channelId = args.channel as string;
|
|
323
|
+
const messageId = args.message as string;
|
|
324
|
+
const emoji = args.emoji as string;
|
|
325
|
+
|
|
326
|
+
const channel = await client.channels.fetch(channelId);
|
|
327
|
+
if (!channel?.isTextBased()) {
|
|
328
|
+
throw new Error('Invalid channel');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const message = await (channel as TextChannel).messages.fetch(messageId);
|
|
332
|
+
await message.react(emoji);
|
|
333
|
+
return { success: true };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
default:
|
|
337
|
+
throw new Error(`Unknown command: ${command}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
package/src/bot.ts
CHANGED
|
@@ -14,10 +14,12 @@ import {
|
|
|
14
14
|
Events,
|
|
15
15
|
Message,
|
|
16
16
|
TextChannel,
|
|
17
|
-
ThreadAutoArchiveDuration
|
|
17
|
+
ThreadAutoArchiveDuration,
|
|
18
|
+
Interaction,
|
|
18
19
|
} from 'discord.js';
|
|
19
20
|
import { claudeQueue } from './queue.js';
|
|
20
21
|
import { db } from './db.js';
|
|
22
|
+
import { startApiServer, buttonHandlers } from './api.js';
|
|
21
23
|
|
|
22
24
|
// Force unbuffered logging
|
|
23
25
|
const log = (msg: string) => process.stdout.write(`[bot] ${msg}\n`);
|
|
@@ -32,6 +34,49 @@ const client = new Client({
|
|
|
32
34
|
|
|
33
35
|
client.once(Events.ClientReady, (c) => {
|
|
34
36
|
log(`Logged in as ${c.user.tag}`);
|
|
37
|
+
|
|
38
|
+
// Start HTTP API server
|
|
39
|
+
const apiPort = parseInt(process.env.API_PORT || '2643');
|
|
40
|
+
startApiServer(client, apiPort);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Handle button interactions
|
|
44
|
+
client.on(Events.InteractionCreate, async (interaction: Interaction) => {
|
|
45
|
+
if (!interaction.isButton()) return;
|
|
46
|
+
|
|
47
|
+
const handler = buttonHandlers.get(interaction.customId);
|
|
48
|
+
if (!handler) {
|
|
49
|
+
await interaction.reply({ content: 'This button has expired.', ephemeral: true });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
if (handler.type === 'inline') {
|
|
55
|
+
await interaction.reply({
|
|
56
|
+
content: handler.content,
|
|
57
|
+
ephemeral: handler.ephemeral ?? false,
|
|
58
|
+
});
|
|
59
|
+
} else if (handler.type === 'webhook') {
|
|
60
|
+
await interaction.deferReply({ ephemeral: true });
|
|
61
|
+
const response = await fetch(handler.url, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
customId: interaction.customId,
|
|
66
|
+
userId: interaction.user.id,
|
|
67
|
+
channelId: interaction.channelId,
|
|
68
|
+
data: handler.data,
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
const result = await response.json() as { content?: string };
|
|
72
|
+
await interaction.editReply({ content: result.content || 'Done.' });
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
log(`Button handler error: ${error}`);
|
|
76
|
+
if (!interaction.replied && !interaction.deferred) {
|
|
77
|
+
await interaction.reply({ content: 'An error occurred.', ephemeral: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
35
80
|
});
|
|
36
81
|
|
|
37
82
|
client.on(Events.MessageCreate, async (message: Message) => {
|