@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.
Files changed (2) hide show
  1. package/README.md +540 -244
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @hardlydifficult/chat
2
2
 
3
- A TypeScript library for building chat bots with unified APIs for Discord and Slack, featuring threading, batching, streaming, commands, and cleanup.
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 '@hardlydifficult/chat';
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
- // Create a Discord or Slack client based on platform
17
- const client = createChatClient('discord', {
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
- // or: 'slack', { token: process.env.SLACK_TOKEN! }
31
+ clientId: process.env.DISCORD_CLIENT_ID!,
20
32
  });
21
33
 
22
- // Listen for messages and respond
23
- client.onMessage((event) => {
24
- event.channel.send('Hello, world!');
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
- // Start listening
28
- await client.start();
45
+ const dispatcher = new CommandDispatcher({ registry, channel });
46
+
47
+ client.onMessage((msg) => dispatcher.handleMessage(msg));
48
+ client.start();
29
49
  ```
30
50
 
31
- ### Alternative Quick Start (with Message Operations)
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
- import { createChatClient } from "@hardlydifficult/chat";
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
- // Connect to Discord or Slack
37
- const client = createChatClient({ type: "discord" });
38
- // or { type: "slack" }
63
+ await msg.update("Final count in thread...");
64
+ await msg.delete({ cascadeReplies: false });
65
+ ```
39
66
 
40
- const channel = await client.connect("channel-id");
41
- await channel.postMessage("Hello world!").addReactions(["👍", "👎"]);
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
- ## Core Abstractions
78
+ ### Streaming Replies
45
79
 
46
- ### Chat Clients
80
+ Stream text into threads with automatic batching, chunking, and platform limit handling.
47
81
 
48
- The library provides platform-specific chat clients through a unified interface.
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
- import { DiscordChatClient, SlackChatClient, createChatClient } from '@hardlydifficult/chat';
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
- // Factory function for any supported platform
54
- const client = createChatClient('discord', { token: '...' });
100
+ ### Threads
55
101
 
56
- // Or instantiate directly
57
- const discordClient = new DiscordChatClient({ token: '...' });
58
- const slackClient = new SlackChatClient({ token: '...' });
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
- Each client supports:
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
- ### Channel and Message
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
- The `Channel` class abstracts messaging operations across platforms.
120
+ Reconnect to an existing thread by ID (e.g., after a restart):
70
121
 
71
122
  ```typescript
72
- // Send a message and get a ReplyMessage reference
73
- const message = await channel.send('Hello');
74
- await message.react('👍');
75
- await message.reply('Thanks for the feedback!');
76
- await message.delete(); // Only the bot's message
77
- await channel.cleanup('all'); // Delete all bot messages in the channel
78
- ```
79
-
80
- Messages are represented by the `Message` interface:
81
-
82
- ```typescript
83
- interface Message {
84
- id: string;
85
- author: Member;
86
- content: string;
87
- timestamp: Date;
88
- reactions: Reaction[];
89
- thread?: Thread;
90
- channel: Channel;
91
-
92
- reply(content: string): Promise<ReplyMessage>;
93
- update(content: string): Promise<void>;
94
- delete(): Promise<void>;
95
- react(emoji: string): Promise<void>;
96
- clearReactions(): Promise<void>;
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
- ### Thread
154
+ ### Typing Indicators
101
155
 
102
- Threads provide an isolated messaging context with its own lifecycle.
156
+ Show typing indicators for long-running work.
103
157
 
104
158
  ```typescript
105
- // Create and use a thread
106
- const thread = await channel.createThread('Discussions');
107
- await thread.send('Let us discuss this here.');
108
- await thread.sendStream('Streaming responses...');
109
- await thread.cleanup('bot'); // Remove only bot messages
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
- ## Message Batching
175
+ > **Slack note:** Slack does not support bot typing indicators. Both methods are no-ops on Slack.
113
176
 
114
- Batching allows grouping multiple messages and managing them collectively.
177
+ ### Message Cleanup
178
+
179
+ Convenience methods for bulk message management.
115
180
 
116
181
  ```typescript
117
- import { MessageBatch } from '@hardlydifficult/chat';
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
- // Create a batch
120
- const batch = channel.batch();
121
- batch.add(channel.send('First'));
122
- batch.add(channel.send('Second'));
189
+ #### Bulk Operations (Enhanced)
123
190
 
124
- // Finish and keep only the latest
125
- await batch.finish({ mode: 'keepLatest' });
191
+ ```typescript
192
+ // Delete up to 100 recent messages
193
+ const deletedCount = await channel.bulkDelete(50);
126
194
 
127
- // Or delete everything
128
- await batch.delete();
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
- The `MessageBatch` class supports:
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
- ## Streaming Replies
211
+ ### Member Matching
138
212
 
139
- The library supports real-time streaming with automatic message updates.
213
+ Resolve users by mention, username, display name, or email.
140
214
 
141
215
  ```typescript
142
- // Use StreamingReply for automatic chunking
143
- const stream = channel.stream({ mode: 'thread' });
144
- stream.append('Hello, ');
145
- await stream.flush(); // Force immediate delivery
146
- stream.append('world!');
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
- The `EditableStreamReply` class allows in-place editing:
223
+ ### Message Tracker
224
+
225
+ Track messages by key for later editing.
151
226
 
152
227
  ```typescript
153
- const stream = new EditableStreamReply(thread, { maxMessageLength: 2000 });
154
- stream.append('Initial ');
155
- await stream.flush();
156
- stream.append('content.');
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
- Both support:
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
- ## Commands
236
+ Track and update messages by key.
167
237
 
168
- A regex-based command system with auto-parsing and context-aware routing.
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 { CommandDispatcher, CommandRegistry, Command } from '@hardlydifficult/chat';
254
+ import { CommandRegistry, CommandDispatcher, setupJobLifecycle } from "@hardlydifficult/chat";
172
255
 
173
256
  const registry = new CommandRegistry();
174
257
 
175
- // Register a command with auto-parsed arguments
176
- registry.register({
177
- name: 'ping',
178
- pattern: /^ping(\s+help)?$/i,
179
- handler: async ({ context, match }) => {
180
- if (match[1]) {
181
- return 'Usage: `ping` or `ping help`';
182
- }
183
- return 'Pong!';
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
- // Use dispatcher to route messages
188
- const dispatcher = new CommandDispatcher(registry);
189
- dispatcher.onMessage((event) => {
190
- event.channel.typingIndicator.start();
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
- ### Command Types
285
+ ## Platform Config
196
286
 
197
- Commands are defined using:
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
- interface Command {
201
- name: string;
202
- pattern: RegExp;
203
- handler: (args: {
204
- context: Context;
205
- match: RegExpExecArray;
206
- event: MessageEvent;
207
- }) => Promise<string | void>;
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
- Contexts include channel, user, and optional state:
317
+ ### Slack
212
318
 
213
319
  ```typescript
214
- interface Context {
215
- channel: Channel;
216
- user: Member;
217
- state?: Map<string, any>;
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
- ### Job Lifecycle
330
+ ## Document Output
222
331
 
223
- Long-running commands support cancel/dismiss UI flows:
332
+ Convert structured documents to platform-native rich text.
224
333
 
225
334
  ```typescript
226
- import { startJobLifecycle } from '@hardlydifficult/chat';
335
+ import { Document, header, text, list, divider, context } from "@hardlydifficult/document-generator";
227
336
 
228
- await startJobLifecycle(thread, {
229
- onCancel: async () => {
230
- // Handle user-initiated cancellation
231
- },
232
- onDismiss: async () => {
233
- // Add dismiss emoji for user to remove later
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
- ## Member Matching
347
+ ### Output Formatting
239
348
 
240
- Resolve users by mention, ID, or fuzzy match:
349
+ Platform-specific message formatting utilities transform abstract document blocks.
350
+
351
+ #### Discord Output
241
352
 
242
353
  ```typescript
243
- import { matchMember } from '@hardlydifficult/chat';
354
+ import { formatDiscord } from '@hardlydifficult/chat/outputters';
244
355
 
245
- // Match a member by mention or name
246
- const member = await matchMember(
247
- channel,
248
- '@john', // or 'john', or '123456789'
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
- // Use aliases for more lenient matching
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
- ## Output Formatting
364
+ #### Slack Output
258
365
 
259
- Convert abstract document blocks into platform-specific formats.
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 { formatDiscord, formatSlack } from '@hardlydifficult/chat';
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
- const document = {
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
- const discordPayload = formatDiscord(document);
273
- const slackBlocks = formatSlack(document);
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
- Supported block types include:
277
- - `text`, `bold`, `italic`, `code`, `pre`
278
- - `header`, `divider`, `section`, `button`
279
- - `mention` (platform-specific user/channel reference)
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
- ## Message Tracking
460
+ Messages that exceed platform limits (Discord: 2000 chars, Slack: 4000 chars) are handled automatically:
282
461
 
283
- Track and update messages by key for efficient dynamic updates.
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
- import { MessageTracker } from '@hardlydifficult/chat';
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
- const tracker = new MessageTracker(channel);
289
- tracker.track('status', async () => channel.send('Updating...'));
290
- tracker.edit('status', async (message) => {
291
- await message.update('Updated!');
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
- ## File Operations
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
- Post and update messages with file attachments.
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
- // Discord: attach files via buildMessagePayload
301
- import { buildMessagePayload } from '@hardlydifficult/chat/discord';
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
- const payload = buildMessagePayload({
304
- content: 'Report',
305
- files: [{ filename: 'report.pdf', data: buffer }]
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 channel.send(payload);
539
+ await recent[0].deleteAll({ cascadeReplies: true });
540
+ ```
309
541
 
310
- // Slack: use built-in file support
311
- await slackClient.postMessage({
312
- channel: 'C123',
313
- text: 'Report',
314
- file: buffer,
315
- filename: 'report.pdf'
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
- ## Setup
551
+ ### Streaming Replies (Enhanced)
320
552
 
321
- ### Discord
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
- 1. Create a bot at [Discord Developer Portal](https://discord.com/developers/applications)
324
- 2. Enable **MESSAGE CONTENT INTENT** in bot settings
325
- 3. Invite with `bot` and `applications.commands` scopes
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
- ### Slack
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
- 1. Create a Slack app at [Slack API](https://api.slack.com/apps)
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
- ## Platform Differences
566
+ Both platforms auto-reconnect via their underlying libraries (discord.js and @slack/bolt). Register callbacks for observability.
336
567
 
337
- | Feature | Discord | Slack |
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
- ## Appendix
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
- Platform-specific error codes are mapped to user-friendly messages:
624
+ Map worker error codes to user-friendly messages.
352
625
 
353
626
  ```typescript
354
- import { isRecoverableError, getErrorFriendlyMessage } from '@hardlydifficult/chat';
627
+ import { formatErrorMessage, isRecoverableError } from '@hardlydifficult/chat/commands';
355
628
 
356
- try {
357
- await channel.send('Failed message');
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
- Recoverable errors include:
368
- - Rate limits (`429 Too Many Requests`)
369
- - Network timeouts
370
- - Temporary unavailability
647
+ ## Platform Setup
371
648
 
372
- ### Cleanup Modes
649
+ ### Discord
373
650
 
374
- The `cleanup` method supports:
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
- | Mode | Behavior |
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
- Example:
384
- ```typescript
385
- await channel.cleanup({ mode: 'since', timestamp: new Date(Date.now() - 86400000) });
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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardlydifficult/chat",
3
- "version": "1.1.69",
3
+ "version": "1.1.71",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [