@hardlydifficult/chat 1.1.69 → 1.1.71
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/README.md +540 -244
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @hardlydifficult/chat
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A unified API for Discord and Slack messaging with rich document support, threading, reactions, bulk operations, streaming, and command management.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -11,376 +11,672 @@ npm install @hardlydifficult/chat
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
13
|
```typescript
|
|
14
|
-
import { createChatClient } from
|
|
14
|
+
import { createChatClient } from "@hardlydifficult/chat";
|
|
15
|
+
|
|
16
|
+
// Connect to Discord or Slack
|
|
17
|
+
const client = createChatClient({ type: "discord" });
|
|
18
|
+
// or { type: "slack" }
|
|
19
|
+
|
|
20
|
+
const channel = await client.connect("channel-id");
|
|
21
|
+
await channel.postMessage("Hello world!").addReactions(["👍", "👎"]);
|
|
22
|
+
```
|
|
15
23
|
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
### Command-Based Bot Example
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { CommandRegistry, CommandDispatcher, DiscordChatClient } from "@hardlydifficult/chat";
|
|
28
|
+
|
|
29
|
+
const client = new DiscordChatClient({
|
|
18
30
|
token: process.env.DISCORD_TOKEN!,
|
|
19
|
-
|
|
31
|
+
clientId: process.env.DISCORD_CLIENT_ID!,
|
|
20
32
|
});
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
const registry = new CommandRegistry();
|
|
35
|
+
|
|
36
|
+
registry.register("ping", {
|
|
37
|
+
description: "Responds with pong",
|
|
38
|
+
execute: async ({ thread, abortController }) => {
|
|
39
|
+
const result = await ping(abortController.signal);
|
|
40
|
+
await thread.post(result);
|
|
41
|
+
thread.complete();
|
|
42
|
+
},
|
|
25
43
|
});
|
|
26
44
|
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
const dispatcher = new CommandDispatcher({ registry, channel });
|
|
46
|
+
|
|
47
|
+
client.onMessage((msg) => dispatcher.handleMessage(msg));
|
|
48
|
+
client.start();
|
|
29
49
|
```
|
|
30
50
|
|
|
31
|
-
|
|
51
|
+
## Core Concepts
|
|
52
|
+
|
|
53
|
+
### Message Operations
|
|
54
|
+
|
|
55
|
+
Messages returned from `postMessage()` support chainable reaction and management operations.
|
|
32
56
|
|
|
33
57
|
```typescript
|
|
34
|
-
|
|
58
|
+
const msg = await channel
|
|
59
|
+
.postMessage("Vote: 1, 2, or 3")
|
|
60
|
+
.addReactions(["1️⃣", "2️⃣", "3️⃣"])
|
|
61
|
+
.onReaction((event) => console.log(`${event.user.username} voted ${event.emoji}`));
|
|
35
62
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
63
|
+
await msg.update("Final count in thread...");
|
|
64
|
+
await msg.delete({ cascadeReplies: false });
|
|
65
|
+
```
|
|
39
66
|
|
|
40
|
-
|
|
41
|
-
|
|
67
|
+
#### Reply Messages
|
|
68
|
+
|
|
69
|
+
Replies can be awaited like promises and support reactions before resolution.
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
const reply = await msg.reply("Counting votes...");
|
|
73
|
+
await reply.update("12 votes for pizza");
|
|
74
|
+
await reply.addReactions(["🎉"]);
|
|
75
|
+
await reply.waitForReactions();
|
|
42
76
|
```
|
|
43
77
|
|
|
44
|
-
|
|
78
|
+
### Streaming Replies
|
|
45
79
|
|
|
46
|
-
|
|
80
|
+
Stream text into threads with automatic batching, chunking, and platform limit handling.
|
|
47
81
|
|
|
48
|
-
|
|
82
|
+
```typescript
|
|
83
|
+
const stream = thread.stream(1000, abortSignal);
|
|
84
|
+
stream.append("Processing...\n");
|
|
85
|
+
stream.append("Result: 42\n");
|
|
86
|
+
await stream.stop();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### Editable Stream
|
|
90
|
+
|
|
91
|
+
Updates a single message in-place instead of creating new messages.
|
|
49
92
|
|
|
50
93
|
```typescript
|
|
51
|
-
|
|
94
|
+
const editableStream = thread.editableStream(2000);
|
|
95
|
+
editableStream.append("Step 1...\n");
|
|
96
|
+
editableStream.append("Step 2...\n");
|
|
97
|
+
await editableStream.stop(); // posts one message, edits it twice
|
|
98
|
+
```
|
|
52
99
|
|
|
53
|
-
|
|
54
|
-
const client = createChatClient('discord', { token: '...' });
|
|
100
|
+
### Threads
|
|
55
101
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
102
|
+
Create and manage conversational threads anchored to messages.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
const thread = await channel.createThread("Topic", "Session-1");
|
|
106
|
+
await thread.post("How can I help?");
|
|
107
|
+
thread.onReply(async (msg) => {
|
|
108
|
+
await thread.post(`You said: ${msg.content}`);
|
|
109
|
+
});
|
|
110
|
+
await thread.delete();
|
|
59
111
|
```
|
|
60
112
|
|
|
61
|
-
|
|
62
|
-
- Message event handling via `onMessage()`
|
|
63
|
-
- Reaction event handling via `onReaction()`
|
|
64
|
-
- Platform-specific startup logic via `start()`
|
|
65
|
-
- Graceful shutdown via `stop()`
|
|
113
|
+
You can also create a thread from an existing message:
|
|
66
114
|
|
|
67
|
-
|
|
115
|
+
```typescript
|
|
116
|
+
const msg = await channel.postMessage("Starting a discussion");
|
|
117
|
+
const thread = await msg.startThread("Discussion Thread", 1440); // auto-archive in minutes
|
|
118
|
+
```
|
|
68
119
|
|
|
69
|
-
|
|
120
|
+
Reconnect to an existing thread by ID (e.g., after a restart):
|
|
70
121
|
|
|
71
122
|
```typescript
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
123
|
+
const thread = channel.openThread(savedThreadId);
|
|
124
|
+
await thread.post("I'm back!");
|
|
125
|
+
thread.onReply(async (msg) => { /* ... */ });
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Batching Messages
|
|
129
|
+
|
|
130
|
+
Group related messages with post-commit operations.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
const batch = await channel.beginBatch({ key: "report" });
|
|
134
|
+
await batch.post("Line 1");
|
|
135
|
+
await batch.post("Line 2");
|
|
136
|
+
await batch.finish();
|
|
137
|
+
|
|
138
|
+
await batch.deleteAll();
|
|
139
|
+
await batch.keepLatest(5);
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
#### With Batch Helper
|
|
143
|
+
|
|
144
|
+
Auto-finish batch even on errors.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
await channel.withBatch(async (batch) => {
|
|
148
|
+
await batch.post("First");
|
|
149
|
+
await batch.post("Second");
|
|
150
|
+
throw new Error("boom"); // batch.finish() called in finally
|
|
151
|
+
});
|
|
98
152
|
```
|
|
99
153
|
|
|
100
|
-
###
|
|
154
|
+
### Typing Indicators
|
|
101
155
|
|
|
102
|
-
|
|
156
|
+
Show typing indicators for long-running work.
|
|
103
157
|
|
|
104
158
|
```typescript
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
await
|
|
108
|
-
|
|
109
|
-
|
|
159
|
+
channel.beginTyping();
|
|
160
|
+
try {
|
|
161
|
+
await longRunningTask();
|
|
162
|
+
} finally {
|
|
163
|
+
channel.endTyping();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await channel.withTyping(() => processMessages());
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
For one-shot use, `sendTyping()` sends a single indicator without automatic refresh:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
await channel.sendTyping();
|
|
110
173
|
```
|
|
111
174
|
|
|
112
|
-
|
|
175
|
+
> **Slack note:** Slack does not support bot typing indicators. Both methods are no-ops on Slack.
|
|
113
176
|
|
|
114
|
-
|
|
177
|
+
### Message Cleanup
|
|
178
|
+
|
|
179
|
+
Convenience methods for bulk message management.
|
|
115
180
|
|
|
116
181
|
```typescript
|
|
117
|
-
|
|
182
|
+
// Keep newest 10, delete rest
|
|
183
|
+
await channel.pruneMessages({ keep: 10 });
|
|
184
|
+
|
|
185
|
+
// Fetch bot's recent messages
|
|
186
|
+
const botMessages = await channel.getRecentBotMessages(50);
|
|
187
|
+
```
|
|
118
188
|
|
|
119
|
-
|
|
120
|
-
const batch = channel.batch();
|
|
121
|
-
batch.add(channel.send('First'));
|
|
122
|
-
batch.add(channel.send('Second'));
|
|
189
|
+
#### Bulk Operations (Enhanced)
|
|
123
190
|
|
|
124
|
-
|
|
125
|
-
|
|
191
|
+
```typescript
|
|
192
|
+
// Delete up to 100 recent messages
|
|
193
|
+
const deletedCount = await channel.bulkDelete(50);
|
|
126
194
|
|
|
127
|
-
//
|
|
128
|
-
await
|
|
195
|
+
// List and filter recent messages
|
|
196
|
+
const botMessages = await channel.getMessages({ limit: 50, author: "me" });
|
|
197
|
+
const sameMessages = await channel.getRecentBotMessages(50);
|
|
198
|
+
|
|
199
|
+
// Keep latest 8 bot messages, delete older ones (opinionated cleanup helper)
|
|
200
|
+
await channel.pruneMessages({ author: "me", limit: 50, keep: 8 });
|
|
201
|
+
|
|
202
|
+
// Get all threads (active and archived) and delete them
|
|
203
|
+
const threads = await channel.getThreads();
|
|
204
|
+
for (const thread of threads) {
|
|
205
|
+
await thread.delete();
|
|
206
|
+
}
|
|
129
207
|
```
|
|
130
208
|
|
|
131
|
-
|
|
132
|
-
- `add(promise)`: Add a pending message to the batch
|
|
133
|
-
- `finish(options)`: Finalize batch with modes: `'keepAll'`, `'keepLatest'`, `'deleteAll'`
|
|
134
|
-
- `delete()`: Delete all batched messages
|
|
135
|
-
- `query()`: Get list of posted messages
|
|
209
|
+
> **Slack note:** Slack has no bulk delete API — messages are deleted one-by-one. Some may fail if the bot lacks permission to delete others' messages. `getThreads()` scans recent channel history for messages with replies.
|
|
136
210
|
|
|
137
|
-
|
|
211
|
+
### Member Matching
|
|
138
212
|
|
|
139
|
-
|
|
213
|
+
Resolve users by mention, username, display name, or email.
|
|
140
214
|
|
|
141
215
|
```typescript
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
await stream.finish(); // Finalize with message deletion or edit
|
|
216
|
+
await channel.resolveMention("@nick"); // "<@U123>"
|
|
217
|
+
await channel.resolveMention("Nick Mancuso"); // "<@U123>"
|
|
218
|
+
await channel.resolveMention("nick@example.com"); // "<@U123>"
|
|
219
|
+
|
|
220
|
+
const member = await channel.findMember("nick");
|
|
148
221
|
```
|
|
149
222
|
|
|
150
|
-
|
|
223
|
+
### Message Tracker
|
|
224
|
+
|
|
225
|
+
Track messages by key for later editing.
|
|
151
226
|
|
|
152
227
|
```typescript
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
await stream.finish();
|
|
228
|
+
const tracker = createMessageTracker((content) => channel.postMessage(content));
|
|
229
|
+
tracker.post("status-worker-1", "🔴 Worker disconnected");
|
|
230
|
+
// Later:
|
|
231
|
+
tracker.edit("status-worker-1", "🟢 Worker reconnected");
|
|
158
232
|
```
|
|
159
233
|
|
|
160
|
-
|
|
161
|
-
- `append(text)`: Add text to buffer
|
|
162
|
-
- `flush()`: Send current buffer as message update
|
|
163
|
-
- `finish()`: Finalize and cleanup
|
|
164
|
-
- `abort()`: Cancel the stream mid-operation
|
|
234
|
+
### Message Tracking
|
|
165
235
|
|
|
166
|
-
|
|
236
|
+
Track and update messages by key.
|
|
167
237
|
|
|
168
|
-
|
|
238
|
+
```typescript
|
|
239
|
+
import { MessageTracker } from '@hardlydifficult/chat';
|
|
240
|
+
|
|
241
|
+
const tracker = new MessageTracker();
|
|
242
|
+
|
|
243
|
+
await tracker.post('greeting', channel.send('Hello!'));
|
|
244
|
+
|
|
245
|
+
// Later, update it
|
|
246
|
+
await tracker.update('greeting', async (msg) => msg.edit('Hello again!'));
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Command System
|
|
250
|
+
|
|
251
|
+
The built-in command framework supports auto-parsed arguments, typing indicators, and message cleanup.
|
|
169
252
|
|
|
170
253
|
```typescript
|
|
171
|
-
import {
|
|
254
|
+
import { CommandRegistry, CommandDispatcher, setupJobLifecycle } from "@hardlydifficult/chat";
|
|
172
255
|
|
|
173
256
|
const registry = new CommandRegistry();
|
|
174
257
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
258
|
+
registry.register("tools", {
|
|
259
|
+
prefix: "merge",
|
|
260
|
+
description: "Merge pull requests",
|
|
261
|
+
args: { type: "rest", argName: "query" },
|
|
262
|
+
execute: async (ctx, args) => {
|
|
263
|
+
const { thread, abortController } = setupJobLifecycle({
|
|
264
|
+
originalMessage: ctx.incomingMessage,
|
|
265
|
+
thread: await ctx.startThread("Merge"),
|
|
266
|
+
abortController: new AbortController(),
|
|
267
|
+
ownerUsername: ctx.incomingMessage.author?.username!,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Use abortController.signal to support cancellation
|
|
271
|
+
const result = await mergePRs(args.query, abortController.signal);
|
|
272
|
+
await thread.post(result);
|
|
273
|
+
thread.complete();
|
|
274
|
+
},
|
|
185
275
|
});
|
|
186
276
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return dispatcher.handle(event);
|
|
277
|
+
const dispatcher = new CommandDispatcher({
|
|
278
|
+
channel,
|
|
279
|
+
registry,
|
|
280
|
+
state: { inFlightCommands: new Set() },
|
|
192
281
|
});
|
|
282
|
+
channel.onMessage((msg) => dispatcher.handleMessage(msg));
|
|
193
283
|
```
|
|
194
284
|
|
|
195
|
-
|
|
285
|
+
## Platform Config
|
|
196
286
|
|
|
197
|
-
|
|
287
|
+
```typescript
|
|
288
|
+
// Discord
|
|
289
|
+
createChatClient({
|
|
290
|
+
type: "discord",
|
|
291
|
+
token: process.env.DISCORD_TOKEN,
|
|
292
|
+
guildId: process.env.DISCORD_GUILD_ID,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Slack
|
|
296
|
+
createChatClient({
|
|
297
|
+
type: "slack",
|
|
298
|
+
token: process.env.SLACK_BOT_TOKEN,
|
|
299
|
+
appToken: process.env.SLACK_APP_TOKEN,
|
|
300
|
+
socketMode: true,
|
|
301
|
+
});
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Discord
|
|
198
305
|
|
|
199
306
|
```typescript
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
307
|
+
import { DiscordChatClient } from '@hardlydifficult/chat/discord';
|
|
308
|
+
|
|
309
|
+
const client = new DiscordChatClient({
|
|
310
|
+
token: 'your-bot-token',
|
|
311
|
+
clientId: 'your-client-id',
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await client.start();
|
|
209
315
|
```
|
|
210
316
|
|
|
211
|
-
|
|
317
|
+
### Slack
|
|
212
318
|
|
|
213
319
|
```typescript
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
320
|
+
import { SlackChatClient } from '@hardlydifficult/chat/slack';
|
|
321
|
+
|
|
322
|
+
const client = new SlackChatClient({
|
|
323
|
+
token: process.env.SLACK_BOT_TOKEN!,
|
|
324
|
+
signingSecret: process.env.SLACK_SIGNING_SECRET!,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
await client.start();
|
|
219
328
|
```
|
|
220
329
|
|
|
221
|
-
|
|
330
|
+
## Document Output
|
|
222
331
|
|
|
223
|
-
|
|
332
|
+
Convert structured documents to platform-native rich text.
|
|
224
333
|
|
|
225
334
|
```typescript
|
|
226
|
-
import {
|
|
335
|
+
import { Document, header, text, list, divider, context } from "@hardlydifficult/document-generator";
|
|
227
336
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
337
|
+
const doc = new Document()
|
|
338
|
+
.add(header("Status Report"))
|
|
339
|
+
.add(divider())
|
|
340
|
+
.add(text("All systems operational."))
|
|
341
|
+
.add(list(["API: ✅", "DB: ✅", "Cache: ✅"]))
|
|
342
|
+
.add(context("Generated at " + new Date().toISOString()));
|
|
343
|
+
|
|
344
|
+
await channel.postMessage(doc);
|
|
236
345
|
```
|
|
237
346
|
|
|
238
|
-
|
|
347
|
+
### Output Formatting
|
|
239
348
|
|
|
240
|
-
|
|
349
|
+
Platform-specific message formatting utilities transform abstract document blocks.
|
|
350
|
+
|
|
351
|
+
#### Discord Output
|
|
241
352
|
|
|
242
353
|
```typescript
|
|
243
|
-
import {
|
|
354
|
+
import { formatDiscord } from '@hardlydifficult/chat/outputters';
|
|
244
355
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
{ fuzzyThreshold: 0.7 }
|
|
250
|
-
);
|
|
356
|
+
const blocks = [
|
|
357
|
+
{ type: 'header', text: 'Welcome' },
|
|
358
|
+
{ type: 'code', language: 'ts', content: 'console.log("hi");' },
|
|
359
|
+
];
|
|
251
360
|
|
|
252
|
-
|
|
253
|
-
const aliases = { 'johnny': 'john' };
|
|
254
|
-
const member = await matchMember(channel, 'johnny', { aliases });
|
|
361
|
+
const payload = formatDiscord(blocks); // Discord embed structure
|
|
255
362
|
```
|
|
256
363
|
|
|
257
|
-
|
|
364
|
+
#### Slack Output
|
|
258
365
|
|
|
259
|
-
|
|
366
|
+
```typescript
|
|
367
|
+
import { formatSlack } from '@hardlydifficult/chat/outputters';
|
|
368
|
+
|
|
369
|
+
const payload = formatSlack(blocks); // Slack Block Kit structure
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Typing
|
|
373
|
+
|
|
374
|
+
All core types are exported for direct use.
|
|
260
375
|
|
|
261
376
|
```typescript
|
|
262
|
-
import {
|
|
377
|
+
import type { Member, Message, Thread, MessageBatch } from "@hardlydifficult/chat";
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## Types
|
|
381
|
+
|
|
382
|
+
### Core Types
|
|
383
|
+
|
|
384
|
+
| Type | Description |
|
|
385
|
+
|--|--|
|
|
386
|
+
| `Agent` | Bot identity (name, avatar, platform ID) |
|
|
387
|
+
| `Command` | Command definition with handler and args |
|
|
388
|
+
| `Context` | Execution context (message, args, reply) |
|
|
389
|
+
| `State` | Persistent state for commands |
|
|
390
|
+
| `ArgShape` | Argument parsing mode: `Text`, `Boolean`, `User`, `Channel`, `Role`, `Number` |
|
|
391
|
+
| `Member` | Platform-agnostic user in a channel |
|
|
392
|
+
| `MessageData` | Abstract message content (content, embeds, files, author, timestamp) |
|
|
393
|
+
| `MessageEvent` | Incoming message event from platform |
|
|
394
|
+
| `Document` | Abstract message block structure for formatting |
|
|
395
|
+
|
|
396
|
+
### Platform-Specific Exports
|
|
397
|
+
|
|
398
|
+
| Platform | Export |
|
|
399
|
+
|--|--|
|
|
400
|
+
| Discord | `DiscordChatClient`, `buildMessagePayload`, `fetchChannelMembers`, `getMessages`, `threadOperations` |
|
|
401
|
+
| Slack | `SlackChatClient`, `buildMessageEvent`, `fetchChannelMembers`, `getMessages`, `getThreads`, `messageOperations`, `removeAllReactions` |
|
|
402
|
+
| Core | `ChatClient`, `Channel`, `Thread`, `Message`, `ReplyMessage`, `StreamingReply`, `EditableStreamReply`, `CommandRegistry`, `CommandDispatcher`, `MessageTracker` |
|
|
403
|
+
|
|
404
|
+
### Streaming Behavior
|
|
405
|
+
|
|
406
|
+
| Feature | Discord | Slack |
|
|
407
|
+
|---------|:-------:|:-----:|
|
|
408
|
+
| Message editing | ✅ | ✅ |
|
|
409
|
+
| Stream chunking | Automatic, 1000 chars | Automatic, 2000 chars |
|
|
410
|
+
| Truncation | Oldest first | Oldest first |
|
|
411
|
+
| Abort support | ✅ | ✅ |
|
|
412
|
+
|
|
413
|
+
### Command Matching
|
|
414
|
+
|
|
415
|
+
- Commands matched by longest-prefix-first
|
|
416
|
+
- Alias conflicts are detected on registration
|
|
417
|
+
- Owner-filtered commands can be restricted to specific user IDs
|
|
418
|
+
|
|
419
|
+
## Features
|
|
420
|
+
|
|
421
|
+
### Bot Identity
|
|
263
422
|
|
|
264
|
-
|
|
265
|
-
type: 'doc',
|
|
266
|
-
content: [
|
|
267
|
-
{ type: 'text', text: 'Hello ' },
|
|
268
|
-
{ type: 'bold', content: 'world' }
|
|
269
|
-
]
|
|
270
|
-
};
|
|
423
|
+
After `connect()`, `client.me` exposes the authenticated bot user:
|
|
271
424
|
|
|
272
|
-
|
|
273
|
-
const
|
|
425
|
+
```typescript
|
|
426
|
+
const client = createChatClient({ type: "slack" });
|
|
427
|
+
await client.connect(channelId);
|
|
428
|
+
|
|
429
|
+
console.log(client.me?.id); // "U09B00R2R96"
|
|
430
|
+
console.log(client.me?.username); // "sprint-bot"
|
|
431
|
+
console.log(client.me?.mention); // "<@U09B00R2R96>"
|
|
274
432
|
```
|
|
275
433
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
434
|
+
### Incoming Messages
|
|
435
|
+
|
|
436
|
+
Subscribe to new messages in a channel. The callback receives a full `Message` object — you can delete it, react to it, or reply in its thread.
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
const unsubscribe = channel.onMessage((msg) => {
|
|
440
|
+
console.log(`${msg.author.username}: ${msg.content}`);
|
|
441
|
+
|
|
442
|
+
// Delete the user's command message
|
|
443
|
+
msg.delete();
|
|
444
|
+
|
|
445
|
+
// React to it
|
|
446
|
+
msg.addReactions(["white_check_mark"]);
|
|
447
|
+
|
|
448
|
+
// Reply in the message's thread
|
|
449
|
+
msg.reply("Got it!");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Later: stop listening
|
|
453
|
+
unsubscribe();
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Messages from the bot itself are automatically filtered out.
|
|
457
|
+
|
|
458
|
+
### Oversized Message Handling
|
|
280
459
|
|
|
281
|
-
|
|
460
|
+
Messages that exceed platform limits (Discord: 2000 chars, Slack: 4000 chars) are handled automatically:
|
|
282
461
|
|
|
283
|
-
|
|
462
|
+
- **`postMessage`**: Sends the full content as a `message.txt` file attachment instead of failing
|
|
463
|
+
- **`update`**: Truncates with `…` (edits cannot attach files on either platform)
|
|
464
|
+
|
|
465
|
+
No caller changes needed — the library handles this transparently.
|
|
466
|
+
|
|
467
|
+
### File Attachments
|
|
468
|
+
|
|
469
|
+
Send files as message attachments.
|
|
284
470
|
|
|
285
471
|
```typescript
|
|
286
|
-
|
|
472
|
+
channel.postMessage("Here's the scan report", {
|
|
473
|
+
files: [
|
|
474
|
+
{ content: Buffer.from(markdownContent), name: "report.md" },
|
|
475
|
+
{ content: "plain text content", name: "notes.txt" },
|
|
476
|
+
],
|
|
477
|
+
});
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
### File Uploads
|
|
287
481
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
482
|
+
```typescript
|
|
483
|
+
// Slack file upload
|
|
484
|
+
await channel.send({
|
|
485
|
+
content: 'Here’s the file',
|
|
486
|
+
files: [{ filename: 'data.csv', content: '1,2,3' }],
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// Discord file upload
|
|
490
|
+
await channel.send({
|
|
491
|
+
content: 'File attached',
|
|
492
|
+
files: [{ filename: 'data.csv', content: Buffer.from('1,2,3') }],
|
|
292
493
|
});
|
|
293
494
|
```
|
|
294
495
|
|
|
295
|
-
|
|
496
|
+
### Dismissable Messages
|
|
497
|
+
|
|
498
|
+
Post a message that the specified user can dismiss by clicking the trash reaction.
|
|
499
|
+
|
|
500
|
+
```typescript
|
|
501
|
+
await channel.postDismissable("Build complete!", user.id);
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
### Declarative Reactions
|
|
296
505
|
|
|
297
|
-
|
|
506
|
+
`setReactions` manages the full reaction state on a message. It diffs against the previous `setReactions` call, removing stale emojis and adding new ones, and replaces any existing reaction handler.
|
|
298
507
|
|
|
299
508
|
```typescript
|
|
300
|
-
|
|
301
|
-
|
|
509
|
+
const msg = await channel.postMessage("PR #42: open");
|
|
510
|
+
|
|
511
|
+
// Set initial reactions
|
|
512
|
+
msg.setReactions(["🟡"], (event) => handlePending(event));
|
|
513
|
+
|
|
514
|
+
// Later: update to merged state — removes 🟡, adds 🟢, swaps handler
|
|
515
|
+
msg.setReactions(["🟢"], (event) => handleMerged(event));
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
### Message Batches
|
|
519
|
+
|
|
520
|
+
Group related posted messages so they can be retrieved and cleaned up together.
|
|
302
521
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
522
|
+
```typescript
|
|
523
|
+
const batch = await channel.beginBatch({ key: "sprint-update" });
|
|
524
|
+
|
|
525
|
+
for (const member of members) {
|
|
526
|
+
const msg = await batch.post(summary(member));
|
|
527
|
+
await msg.reply(detail(member));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
await batch.post(callouts);
|
|
531
|
+
await batch.finish();
|
|
532
|
+
|
|
533
|
+
const recent = await channel.getBatches({
|
|
534
|
+
key: "sprint-update",
|
|
535
|
+
author: "me",
|
|
536
|
+
limit: 5,
|
|
306
537
|
});
|
|
307
538
|
|
|
308
|
-
await
|
|
539
|
+
await recent[0].deleteAll({ cascadeReplies: true });
|
|
540
|
+
```
|
|
309
541
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
542
|
+
For safer lifecycle handling, use `withBatch` (auto-finishes in `finally`):
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
await channel.withBatch({ key: "sprint-update" }, async (batch) => {
|
|
546
|
+
await batch.post("Part 1");
|
|
547
|
+
await batch.post("Part 2");
|
|
316
548
|
});
|
|
317
549
|
```
|
|
318
550
|
|
|
319
|
-
|
|
551
|
+
### Streaming Replies (Enhanced)
|
|
320
552
|
|
|
321
|
-
|
|
553
|
+
Both `streamReply()`, `thread.stream()`, and `thread.editableStream()` accept an optional `AbortSignal` to automatically stop the stream on cancellation. After abort, `append()` becomes a no-op and `stop()` is called automatically.
|
|
322
554
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
4. Set `DISCORD_TOKEN` environment variable
|
|
555
|
+
```typescript
|
|
556
|
+
const controller = new AbortController();
|
|
557
|
+
const stream = thread.stream(2000, controller.signal);
|
|
327
558
|
|
|
328
|
-
|
|
559
|
+
stream.append("working...\n");
|
|
560
|
+
controller.abort(); // auto-stops, future appends are ignored
|
|
561
|
+
console.log(stream.content); // "working...\n" — only pre-abort text
|
|
562
|
+
```
|
|
329
563
|
|
|
330
|
-
|
|
331
|
-
2. Add **Incoming Webhooks** and **Bot User Token** scopes
|
|
332
|
-
3. Install to workspace and copy token
|
|
333
|
-
4. Set `SLACK_TOKEN` environment variable
|
|
564
|
+
### Connection Resilience
|
|
334
565
|
|
|
335
|
-
|
|
566
|
+
Both platforms auto-reconnect via their underlying libraries (discord.js and @slack/bolt). Register callbacks for observability.
|
|
336
567
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
| Message Length Limit | 2000 characters | 4000 characters |
|
|
340
|
-
| Thread Support | Text channel threads | Conversations with replies |
|
|
341
|
-
| File Uploads | Via `buildMessagePayload` | Via `chat.postMessage` |
|
|
342
|
-
| Typing Indicators | Supported via `typingIndicator` | Supported via `chat.scheduledMsg` |
|
|
343
|
-
| Message History | Requires **MESSAGE CONTENT INTENT**| Requires `history` scope |
|
|
344
|
-
| Mention Resolution | By ID or username#discriminator | By `@username` or user ID |
|
|
345
|
-
| Emoji Reactions | Unicode or custom emoji (with ID) | Custom emoji only by name |
|
|
568
|
+
```typescript
|
|
569
|
+
const client = createChatClient({ type: "discord" });
|
|
346
570
|
|
|
347
|
-
|
|
571
|
+
client.onDisconnect((reason) => {
|
|
572
|
+
console.log("Disconnected:", reason);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
client.onError((error) => {
|
|
576
|
+
console.error("Connection error:", error);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
await client.disconnect(); // clean shutdown
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Both callbacks return an unsubscribe function.
|
|
583
|
+
|
|
584
|
+
### Reaction Management
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
// Add and remove reactions
|
|
588
|
+
await message.react('👍');
|
|
589
|
+
await message.removeReaction('👍', userId);
|
|
590
|
+
|
|
591
|
+
// Remove all bot reactions (Slack-specific)
|
|
592
|
+
await slackChatClient.removeAllReactions(channelId, ts, botUserId);
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Member Matching
|
|
596
|
+
|
|
597
|
+
Match users by ID, mention, or fuzzy alias.
|
|
598
|
+
|
|
599
|
+
```typescript
|
|
600
|
+
import { findMember } from '@hardlydifficult/chat/memberMatching';
|
|
601
|
+
|
|
602
|
+
const member = findMember(guildMembers, '@alice'); // mentions
|
|
603
|
+
const member = findMember(guildMembers, 'alice'); // fuzzy match
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
### Job Lifecycle (Threaded Commands)
|
|
607
|
+
|
|
608
|
+
Long-running commands support cancel/dismiss flow.
|
|
609
|
+
|
|
610
|
+
```typescript
|
|
611
|
+
import { withCancelListener } from '@hardlydifficult/chat/commands/jobLifecycle';
|
|
612
|
+
|
|
613
|
+
await withCancelListener(
|
|
614
|
+
thread,
|
|
615
|
+
async () => {
|
|
616
|
+
// Long-running job
|
|
617
|
+
},
|
|
618
|
+
{ userId: message.author.id },
|
|
619
|
+
);
|
|
620
|
+
```
|
|
348
621
|
|
|
349
622
|
### Error Handling
|
|
350
623
|
|
|
351
|
-
|
|
624
|
+
Map worker error codes to user-friendly messages.
|
|
352
625
|
|
|
353
626
|
```typescript
|
|
354
|
-
import {
|
|
627
|
+
import { formatErrorMessage, isRecoverableError } from '@hardlydifficult/chat/commands';
|
|
355
628
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
} catch (error) {
|
|
359
|
-
if (isRecoverableError(error)) {
|
|
360
|
-
// Retry with backoff
|
|
361
|
-
} else {
|
|
362
|
-
console.log(getErrorFriendlyMessage(error));
|
|
363
|
-
}
|
|
629
|
+
if (isRecoverableError(error)) {
|
|
630
|
+
// Retry logic
|
|
364
631
|
}
|
|
632
|
+
|
|
633
|
+
const message = formatErrorMessage(error.code);
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
### Constants
|
|
637
|
+
|
|
638
|
+
Platform message length limits.
|
|
639
|
+
|
|
640
|
+
```typescript
|
|
641
|
+
import { DISCORD_MAX_MESSAGE_LENGTH, SLACK_MAX_MESSAGE_LENGTH } from '@hardlydifficult/chat';
|
|
642
|
+
|
|
643
|
+
console.log(DISCORD_MAX_MESSAGE_LENGTH); // 2000
|
|
644
|
+
console.log(SLACK_MAX_MESSAGE_LENGTH); // 4000
|
|
365
645
|
```
|
|
366
646
|
|
|
367
|
-
|
|
368
|
-
- Rate limits (`429 Too Many Requests`)
|
|
369
|
-
- Network timeouts
|
|
370
|
-
- Temporary unavailability
|
|
647
|
+
## Platform Setup
|
|
371
648
|
|
|
372
|
-
###
|
|
649
|
+
### Discord
|
|
373
650
|
|
|
374
|
-
|
|
651
|
+
1. Create bot at [Discord Developer Portal](https://discord.com/developers/applications)
|
|
652
|
+
2. Enable Gateway Intents: `GUILDS`, `GUILD_MEMBERS`, `GUILD_MESSAGES`, `GUILD_MESSAGE_REACTIONS`, `MESSAGE_CONTENT`
|
|
653
|
+
3. Bot permissions: `Send Messages`, `Add Reactions`, `Read Message History`, `Manage Messages` (for bulk delete), `Create Public Threads`, `Send Messages in Threads`
|
|
654
|
+
4. Set `DISCORD_TOKEN` and `DISCORD_GUILD_ID` env vars
|
|
375
655
|
|
|
376
|
-
|
|
377
|
-
|-----------|----------------------------------------------|
|
|
378
|
-
| `all` | Delete all messages (bot + users) |
|
|
379
|
-
| `bot` | Delete only bot-authored messages |
|
|
380
|
-
| `user` | Delete only user-authored messages |
|
|
381
|
-
| `since` | Delete messages newer than timestamp |
|
|
656
|
+
### Slack
|
|
382
657
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
658
|
+
1. Create app at [Slack API](https://api.slack.com/apps)
|
|
659
|
+
2. Enable Socket Mode, generate App Token
|
|
660
|
+
3. Bot scopes: `chat:write`, `chat:write.public`, `reactions:write`, `reactions:read`, `channels:history`, `channels:read`, `files:write`, `users:read`
|
|
661
|
+
4. Subscribe to events: `reaction_added`, `message.channels`
|
|
662
|
+
5. Set `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` env vars
|
|
663
|
+
|
|
664
|
+
## Appendix
|
|
665
|
+
|
|
666
|
+
### Platform Differences
|
|
667
|
+
|
|
668
|
+
| Feature | Discord | Slack |
|
|
669
|
+
|------------------------|-----------------------------------|-----------------------------------|
|
|
670
|
+
| Typing indicators | ✅ Supported | ❌ No API support (no-op) |
|
|
671
|
+
| Message length limit | 2000 characters | 4000 characters |
|
|
672
|
+
| Thread creation | Explicit thread channel | Implicit via parent message ts |
|
|
673
|
+
| Bulk delete | ✅ Up to 100 messages at once | ❌ Must delete one-by-one |
|
|
674
|
+
| Emoji format | Plain Unicode or `:name:` | Colon-wrapped `:name:` |
|
|
675
|
+
| File uploads | As attachments | Via `filesUploadV2` API |
|
|
676
|
+
|
|
677
|
+
### Message Limits
|
|
678
|
+
|
|
679
|
+
| Platform | Max Message Length | Notes |
|
|
680
|
+
|----------|--------------------|-------|
|
|
681
|
+
| Discord | 2000 | Embed-only messages may be larger |
|
|
682
|
+
| Slack | 4000 | Per block element; message may contain many |
|