@hardlydifficult/chat 1.1.63 → 1.1.65

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 +246 -216
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @hardlydifficult/chat
2
2
 
3
- Unified API for Discord and Slack messaging with rich document support.
3
+ A unified API for Discord and Slack messaging with rich document support, threading, reactions, and bulk operations.
4
4
 
5
5
  ## Installation
6
6
 
@@ -13,32 +13,248 @@ npm install @hardlydifficult/chat
13
13
  ```typescript
14
14
  import { createChatClient } from "@hardlydifficult/chat";
15
15
 
16
+ // Connect to Discord or Slack
16
17
  const client = createChatClient({ type: "discord" });
17
- const channel = await client.connect(channelId);
18
- console.log(client.me?.mention); // "<@123...>"
18
+ // or { type: "slack" }
19
19
 
20
- // Post messages
21
- await channel.postMessage("Hello!");
20
+ const channel = await client.connect("channel-id");
21
+ await channel.postMessage("Hello world!").addReactions(["👍", "👎"]);
22
+ ```
22
23
 
23
- // Listen for incoming messages
24
- channel.onMessage((msg) => {
25
- console.log(`${msg.author.username}: ${msg.content}`);
24
+ ## Core Concepts
25
+
26
+ ### Message Operations
27
+
28
+ Messages returned from `postMessage()` support chainable reaction and management operations.
29
+
30
+ ```typescript
31
+ const msg = await channel
32
+ .postMessage("Vote: 1, 2, or 3")
33
+ .addReactions(["1️⃣", "2️⃣", "3️⃣"])
34
+ .onReaction((event) => console.log(`${event.user.username} voted ${event.emoji}`));
35
+
36
+ await msg.update("Final count in thread...");
37
+ await msg.delete({ cascadeReplies: false });
38
+ ```
39
+
40
+ #### Reply Messages
41
+
42
+ Replies can be awaited like promises and support reactions before resolution.
43
+
44
+ ```typescript
45
+ const reply = await msg.reply("Counting votes...");
46
+ await reply.update("12 votes for pizza");
47
+ await reply.addReactions(["🎉"]);
48
+ await reply.waitForReactions();
49
+ ```
50
+
51
+ ### Streaming Replies
52
+
53
+ Stream text into threads with automatic batching, chunking, and platform limit handling.
54
+
55
+ ```typescript
56
+ const stream = thread.stream(1000, abortSignal);
57
+ stream.append("Processing...\n");
58
+ stream.append("Result: 42\n");
59
+ await stream.stop();
60
+ ```
61
+
62
+ #### Editable Stream
63
+
64
+ Updates a single message in-place instead of creating new messages.
65
+
66
+ ```typescript
67
+ const editableStream = thread.editableStream(2000);
68
+ editableStream.append("Step 1...\n");
69
+ editableStream.append("Step 2...\n");
70
+ await editableStream.stop(); // posts one message, edits it twice
71
+ ```
72
+
73
+ ### Threads
74
+
75
+ Create and manage conversational threads anchored to messages.
76
+
77
+ ```typescript
78
+ const thread = await channel.createThread("Topic", "Session-1");
79
+ await thread.post("How can I help?");
80
+ thread.onReply(async (msg) => {
81
+ await thread.post(`You said: ${msg.content}`);
26
82
  });
83
+ await thread.delete();
27
84
  ```
28
85
 
29
- ## Configuration
86
+ ### Batching Messages
87
+
88
+ Group related messages with post-commit operations.
89
+
90
+ ```typescript
91
+ const batch = await channel.beginBatch({ key: "report" });
92
+ await batch.post("Line 1");
93
+ await batch.post("Line 2");
94
+ await batch.finish();
95
+
96
+ await batch.deleteAll();
97
+ await batch.keepLatest(5);
98
+ ```
99
+
100
+ #### With Batch Helper
101
+
102
+ Auto-finish batch even on errors.
103
+
104
+ ```typescript
105
+ await channel.withBatch(async (batch) => {
106
+ await batch.post("First");
107
+ await batch.post("Second");
108
+ throw new Error("boom"); // batch.finish() called in finally
109
+ });
110
+ ```
111
+
112
+ ### Typing Indicators
113
+
114
+ Show typing indicators for long-running work.
30
115
 
31
116
  ```typescript
32
- // Discord - env vars: DISCORD_TOKEN, DISCORD_GUILD_ID
33
- createChatClient({ type: "discord" });
34
- createChatClient({ type: "discord", token: "...", guildId: "..." });
117
+ channel.beginTyping();
118
+ try {
119
+ await longRunningTask();
120
+ } finally {
121
+ channel.endTyping();
122
+ }
123
+
124
+ await channel.withTyping(() => processMessages());
125
+ ```
126
+
127
+ ### Message Cleanup
128
+
129
+ Convenience methods for bulk message management.
130
+
131
+ ```typescript
132
+ // Keep newest 10, delete rest
133
+ await channel.pruneMessages({ keep: 10 });
134
+
135
+ // Fetch bot's recent messages
136
+ const botMessages = await channel.getRecentBotMessages(50);
137
+ ```
138
+
139
+ ### Member Matching
140
+
141
+ Resolve users by mention, username, display name, or email.
142
+
143
+ ```typescript
144
+ await channel.resolveMention("@nick"); // "<@U123>"
145
+ await channel.resolveMention("Nick Mancuso"); // "<@U123>"
146
+ await channel.resolveMention("nick@example.com"); // "<@U123>"
147
+
148
+ const member = await channel.findMember("nick");
149
+ ```
150
+
151
+ ### Message Tracker
152
+
153
+ Track messages by key for later editing.
154
+
155
+ ```typescript
156
+ const tracker = createMessageTracker((content) => channel.postMessage(content));
157
+ tracker.post("status-worker-1", "🔴 Worker disconnected");
158
+ // Later:
159
+ tracker.edit("status-worker-1", "🟢 Worker reconnected");
160
+ ```
161
+
162
+ ## Command System
163
+
164
+ The built-in command framework supports auto-parsed arguments, typing indicators, and message cleanup.
165
+
166
+ ```typescript
167
+ import { CommandRegistry, CommandDispatcher, setupJobLifecycle } from "@hardlydifficult/chat";
168
+
169
+ const registry = new CommandRegistry();
170
+
171
+ registry.register("tools", {
172
+ prefix: "merge",
173
+ description: "Merge pull requests",
174
+ args: { type: "rest", argName: "query" },
175
+ execute: async (ctx, args) => {
176
+ const { thread, abortController } = setupJobLifecycle({
177
+ originalMessage: ctx.incomingMessage,
178
+ thread: await ctx.startThread("Merge"),
179
+ abortController: new AbortController(),
180
+ ownerUsername: ctx.incomingMessage.author?.username!,
181
+ });
182
+
183
+ // Use abortController.signal to support cancellation
184
+ const result = await mergePRs(args.query, abortController.signal);
185
+ await thread.post(result);
186
+ thread.complete();
187
+ },
188
+ });
189
+
190
+ const dispatcher = new CommandDispatcher({
191
+ channel,
192
+ registry,
193
+ state: { inFlightCommands: new Set() },
194
+ });
195
+ channel.onMessage((msg) => dispatcher.handleMessage(msg));
196
+ ```
197
+
198
+ ## Platform Config
199
+
200
+ ```typescript
201
+ // Discord
202
+ createChatClient({
203
+ type: "discord",
204
+ token: process.env.DISCORD_TOKEN,
205
+ guildId: process.env.DISCORD_GUILD_ID,
206
+ });
207
+
208
+ // Slack
209
+ createChatClient({
210
+ type: "slack",
211
+ token: process.env.SLACK_BOT_TOKEN,
212
+ appToken: process.env.SLACK_APP_TOKEN,
213
+ socketMode: true,
214
+ });
215
+ ```
216
+
217
+ ## Document Output
218
+
219
+ Convert structured documents to platform-native rich text.
220
+
221
+ ```typescript
222
+ import { Document, header, text, list, divider, context } from "@hardlydifficult/document-generator";
223
+
224
+ const doc = new Document()
225
+ .add(header("Status Report"))
226
+ .add(divider())
227
+ .add(text("All systems operational."))
228
+ .add(list(["API: ✅", "DB: ✅", "Cache: ✅"]))
229
+ .add(context("Generated at " + new Date().toISOString()));
230
+
231
+ await channel.postMessage(doc);
232
+ ```
233
+
234
+ ## Typing
35
235
 
36
- // Slack - env vars: SLACK_BOT_TOKEN, SLACK_APP_TOKEN
37
- createChatClient({ type: "slack" });
38
- createChatClient({ type: "slack", token: "...", appToken: "..." });
236
+ All core types are exported for direct use.
237
+
238
+ ```typescript
239
+ import type { Member, Message, Thread, MessageBatch } from "@hardlydifficult/chat";
39
240
  ```
40
241
 
41
- ## Bot Identity
242
+ ## Appendix
243
+
244
+ ### Platform Differences
245
+
246
+ | Feature | Discord | Slack |
247
+ |------------------------|-----------------------------------|-----------------------------------|
248
+ | Typing indicators | ✅ Supported | ❌ No API support (no-op) |
249
+ | Message length limit | 2000 characters | 4000 characters |
250
+ | Thread creation | Explicit thread channel | Implicit via parent message ts |
251
+ | Bulk delete | ✅ Up to 100 messages at once | ❌ Must delete one-by-one |
252
+ | Emoji format | Plain Unicode or `:name:` | Colon-wrapped `:name:` |
253
+ | File uploads | As attachments | Via `filesUploadV2` API |
254
+
255
+ ### Additional Features from Current README
256
+
257
+ #### Bot Identity
42
258
 
43
259
  After `connect()`, `client.me` exposes the authenticated bot user:
44
260
 
@@ -51,7 +267,7 @@ console.log(client.me?.username); // "sprint-bot"
51
267
  console.log(client.me?.mention); // "<@U09B00R2R96>"
52
268
  ```
53
269
 
54
- ## Incoming Messages
270
+ #### Incoming Messages
55
271
 
56
272
  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.
57
273
 
@@ -75,26 +291,7 @@ unsubscribe();
75
291
 
76
292
  Messages from the bot itself are automatically filtered out.
77
293
 
78
- ## Posting Messages
79
-
80
- Content can be a string or a `Document` from `@hardlydifficult/document-generator`.
81
-
82
- ```typescript
83
- // Simple text
84
- channel.postMessage("Hello!");
85
-
86
- // Rich document (auto-converted to Discord Embed / Slack Block Kit)
87
- import { Document } from "@hardlydifficult/document-generator";
88
-
89
- const report = new Document()
90
- .header("Daily Report")
91
- .text("Here are today's **highlights**:")
92
- .list(["Feature A completed", "Bug B fixed", "99.9% uptime"]);
93
-
94
- channel.postMessage(report);
95
- ```
96
-
97
- ### Oversized Message Handling
294
+ #### Oversized Message Handling
98
295
 
99
296
  Messages that exceed platform limits (Discord: 2000 chars, Slack: 4000 chars) are handled automatically:
100
297
 
@@ -103,7 +300,7 @@ Messages that exceed platform limits (Discord: 2000 chars, Slack: 4000 chars) ar
103
300
 
104
301
  No caller changes needed — the library handles this transparently.
105
302
 
106
- ### File Attachments
303
+ #### File Attachments
107
304
 
108
305
  Send files as message attachments.
109
306
 
@@ -116,39 +313,15 @@ channel.postMessage("Here's the scan report", {
116
313
  });
117
314
  ```
118
315
 
119
- ## Message Operations
120
-
121
- ```typescript
122
- const msg = await channel.postMessage("Hello");
123
-
124
- await msg.update("Updated content");
125
- msg.reply("Thread reply");
126
- await msg.delete();
127
- await msg.delete({ cascadeReplies: false }); // keep thread replies
128
- ```
316
+ #### Dismissable Messages
129
317
 
130
- ### Reactions
318
+ Post a message that the specified user can dismiss by clicking the trash reaction.
131
319
 
132
320
  ```typescript
133
- const msg = await channel
134
- .postMessage("Pick one")
135
- .addReactions(["👍", "👎"])
136
- .onReaction((event) => {
137
- console.log(`${event.user.username} reacted with ${event.emoji}`);
138
- });
139
-
140
- msg.offReaction(); // stop listening
141
-
142
- // Remove the bot's own reactions
143
- msg.removeReactions(["👍", "👎"]);
144
-
145
- // Remove all reactions from the message (from all users)
146
- msg.removeAllReactions();
321
+ await channel.postDismissable("Build complete!", user.id);
147
322
  ```
148
323
 
149
- > **Slack note:** Slack only allows removing the bot's own reactions. `removeAllReactions()` removes the bot's reactions for every emoji on the message but cannot remove other users' reactions.
150
-
151
- ### Declarative Reactions
324
+ #### Declarative Reactions
152
325
 
153
326
  `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.
154
327
 
@@ -162,37 +335,7 @@ msg.setReactions(["🟡"], (event) => handlePending(event));
162
335
  msg.setReactions(["🟢"], (event) => handleMerged(event));
163
336
  ```
164
337
 
165
- ### Dismissable Messages
166
-
167
- Post a message that the specified user can dismiss by clicking the trash reaction.
168
-
169
- ```typescript
170
- await channel.postDismissable("Build complete!", user.id);
171
- ```
172
-
173
- ### Message Tracker
174
-
175
- Track posted messages by key for later editing. Useful for status messages that should be updated in-place (e.g., "Worker disconnected" → "Worker reconnected").
176
-
177
- ```typescript
178
- import { createMessageTracker } from "@hardlydifficult/chat";
179
-
180
- const tracker = createMessageTracker((content) => channel.postMessage(content));
181
-
182
- // Post and track by key
183
- tracker.post("worker-1", "🔴 Worker disconnected: Server A");
184
-
185
- // Later, edit the tracked message
186
- const postedAt = tracker.getPostedAt("worker-1");
187
- if (postedAt !== undefined) {
188
- const downtime = Date.now() - postedAt.getTime();
189
- tracker.edit("worker-1", `🟢 Worker reconnected: Server A (down for ${downtime}ms)`);
190
- }
191
- ```
192
-
193
- `post()` is fire-and-forget. `edit()` handles the race where the edit arrives before the original post completes — it chains on the stored promise.
194
-
195
- ### Message Batches
338
+ #### Message Batches
196
339
 
197
340
  Group related posted messages so they can be retrieved and cleaned up together.
198
341
 
@@ -225,42 +368,7 @@ await channel.withBatch({ key: "sprint-update" }, async (batch) => {
225
368
  });
226
369
  ```
227
370
 
228
- `MessageBatch` includes:
229
-
230
- - `id`, `key`, `author`, `createdAt`, `closedAt`, `isFinished`
231
- - `messages` (tracked message refs)
232
- - `post(content, options?)`
233
- - `deleteAll(options?)`
234
- - `keepLatest(n, options?)`
235
- - `finish()`
236
-
237
- ### Threads
238
-
239
- Create a thread, post messages, and listen for replies. The `Thread` object is the primary interface — all threading internals are hidden.
240
-
241
- ```typescript
242
- // Create a thread (posts root message + starts thread)
243
- const thread = await channel.createThread("Starting a session!", "Session");
244
-
245
- // Post in the thread
246
- const msg = await thread.post("How can I help?");
247
-
248
- // Listen for replies
249
- thread.onReply(async (msg) => {
250
- await thread.post(`Got: ${msg.content}`);
251
- // msg.reply() also posts in the same thread
252
- await msg.reply("Thanks!");
253
- });
254
-
255
- // Post with file attachments
256
- await thread.post("Here's the report", [
257
- { content: "# Report\n...", name: "report.md" },
258
- ]);
259
-
260
- // Stop listening and clean up
261
- thread.offReply();
262
- await thread.delete();
263
- ```
371
+ #### Threads (Enhanced)
264
372
 
265
373
  You can also create a thread from an existing message:
266
374
 
@@ -277,39 +385,7 @@ await thread.post("I'm back!");
277
385
  thread.onReply(async (msg) => { /* ... */ });
278
386
  ```
279
387
 
280
- `msg.reply()` supports file attachments:
281
-
282
- ```typescript
283
- const msg = await channel.postMessage("Processing...");
284
- await msg.reply("Done!", [{ content: resultData, name: "result.json" }]);
285
- ```
286
-
287
- > **Note:** `channel.onMessage()` only fires for top-level channel messages, not thread replies. Use `thread.onReply()` to listen for thread messages.
288
-
289
- > **Slack note:** Slack threads are implicit — `createThread()` posts a root message and uses its timestamp as the thread ID.
290
-
291
- ### Streaming Replies
292
-
293
- Buffer text and flush it as thread replies at a regular interval. Useful for commands that produce output over time (e.g., streaming CLI output). Long text is automatically chunked to fit within platform message limits.
294
-
295
- ```typescript
296
- const msg = await channel.postMessage("Running...");
297
- const stream = msg.streamReply(2000); // flush every 2s
298
-
299
- stream.append("output line 1\n");
300
- stream.append("output line 2\n");
301
- // ... text is batched and sent as replies every 2 seconds
302
-
303
- await stream.stop(); // flushes remaining text and stops the timer
304
- console.log(stream.content); // full accumulated text across all flushes
305
- ```
306
-
307
- `flush()` sends buffered text immediately without waiting for the next interval:
308
-
309
- ```typescript
310
- stream.append("important output");
311
- await stream.flush();
312
- ```
388
+ #### Streaming Replies (Enhanced)
313
389
 
314
390
  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.
315
391
 
@@ -322,35 +398,7 @@ controller.abort(); // auto-stops, future appends are ignored
322
398
  console.log(stream.content); // "working...\n" — only pre-abort text
323
399
  ```
324
400
 
325
- ## Mentions
326
-
327
- Resolve fuzzy member queries directly to mention strings.
328
-
329
- ```typescript
330
- const mention = await channel.resolveMention("Nick Mancuso");
331
- await channel.postMessage(`Hey ${mention ?? "@nick"}, check this out!`);
332
- ```
333
-
334
- You can still inspect members directly:
335
-
336
- ```typescript
337
- const members = await channel.getMembers();
338
- const member = await channel.findMember("@alice");
339
- ```
340
-
341
- Each `Member` has `id`, `username`, `displayName`, `mention`, and (when available) `email`.
342
-
343
- ## Typing Indicator
344
-
345
- Show a "typing" indicator while processing. `withTyping` sends the indicator immediately, refreshes it every 8 seconds, and cleans up automatically when the function completes.
346
-
347
- ```typescript
348
- const result = await channel.withTyping(async () => {
349
- // typing indicator stays active during this work
350
- return await doExpensiveWork();
351
- });
352
- await channel.postMessage(result);
353
- ```
401
+ #### Typing Indicator (Enhanced)
354
402
 
355
403
  For one-shot use, `sendTyping()` sends a single indicator without automatic refresh:
356
404
 
@@ -360,9 +408,7 @@ await channel.sendTyping();
360
408
 
361
409
  > **Slack note:** Slack does not support bot typing indicators. Both methods are no-ops on Slack.
362
410
 
363
- ## Bulk Operations
364
-
365
- Delete messages and manage threads in bulk.
411
+ #### Bulk Operations (Enhanced)
366
412
 
367
413
  ```typescript
368
414
  // Delete up to 100 recent messages
@@ -384,7 +430,7 @@ for (const thread of threads) {
384
430
 
385
431
  > **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.
386
432
 
387
- ## Connection Resilience
433
+ #### Connection Resilience
388
434
 
389
435
  Both platforms auto-reconnect via their underlying libraries (discord.js and @slack/bolt). Register callbacks for observability.
390
436
 
@@ -419,20 +465,4 @@ Both callbacks return an unsubscribe function.
419
465
  2. Enable Socket Mode, generate App Token
420
466
  3. Bot scopes: `chat:write`, `chat:write.public`, `reactions:write`, `reactions:read`, `channels:history`, `channels:read`, `files:write`, `users:read`
421
467
  4. Subscribe to events: `reaction_added`, `message.channels`
422
- 5. Set `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` env vars
423
-
424
- ## Platform Differences
425
-
426
- | Feature | Discord | Slack |
427
- | ----------------- | --------------------------------------------- | ------------------------------------------------ |
428
- | Message limit | 2000 chars (auto-attaches as file if over) | 4000 chars (auto-uploads as file if over) |
429
- | Incoming messages | Full support | Full support |
430
- | Typing indicator | Full support | No-op (unsupported by Slack bot API) |
431
- | File attachments | `AttachmentBuilder` | `filesUploadV2` |
432
- | Thread creation | Creates named thread channel on message | Returns message timestamp (threads are implicit) |
433
- | Thread replies | Messages arrive with `channelId = threadId` | Messages arrive with `thread_ts` on parent channel |
434
- | Bulk delete | Native `bulkDelete` API (fast) | One-by-one deletion (slower, may partially fail) |
435
- | Get threads | `fetchActive` + `fetchArchived` | Scans channel history for threaded messages |
436
- | Delete thread | `ThreadChannel.delete()` | Deletes parent message and all replies |
437
- | Get members | Guild members filtered by channel permissions | `conversations.members` + `users.info` |
438
- | Auto-reconnect | Handled by discord.js | Handled by `@slack/bolt` Socket Mode |
468
+ 5. Set `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` env vars
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardlydifficult/chat",
3
- "version": "1.1.63",
3
+ "version": "1.1.65",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "files": [