@animalabs/agent-framework 0.22.3 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/README.md +105 -3
  2. package/dist/src/agent.d.ts +36 -10
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +76 -32
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/code-execution/index.d.ts +1 -1
  7. package/dist/src/code-execution/index.d.ts.map +1 -1
  8. package/dist/src/code-execution/index.js.map +1 -1
  9. package/dist/src/code-execution/py-runner.d.ts +34 -2
  10. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  11. package/dist/src/code-execution/py-runner.js +72 -16
  12. package/dist/src/code-execution/py-runner.js.map +1 -1
  13. package/dist/src/code-execution/runtime-py.d.ts +3 -2
  14. package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
  15. package/dist/src/code-execution/runtime-py.js +47 -7
  16. package/dist/src/code-execution/runtime-py.js.map +1 -1
  17. package/dist/src/code-execution/tool-definition.d.ts +12 -7
  18. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  19. package/dist/src/code-execution/tool-definition.js +59 -11
  20. package/dist/src/code-execution/tool-definition.js.map +1 -1
  21. package/dist/src/config-checks.d.ts +2 -2
  22. package/dist/src/config-checks.d.ts.map +1 -1
  23. package/dist/src/config-checks.js +6 -2
  24. package/dist/src/config-checks.js.map +1 -1
  25. package/dist/src/framework.d.ts +744 -120
  26. package/dist/src/framework.d.ts.map +1 -1
  27. package/dist/src/framework.js +3546 -906
  28. package/dist/src/framework.js.map +1 -1
  29. package/dist/src/gate/event-gate.d.ts.map +1 -1
  30. package/dist/src/gate/event-gate.js +13 -6
  31. package/dist/src/gate/event-gate.js.map +1 -1
  32. package/dist/src/held-writes.d.ts +16 -0
  33. package/dist/src/held-writes.d.ts.map +1 -0
  34. package/dist/src/held-writes.js +56 -0
  35. package/dist/src/held-writes.js.map +1 -0
  36. package/dist/src/index.d.ts +7 -3
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +8 -4
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/mcpl/capability-grant.d.ts +4 -0
  41. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  42. package/dist/src/mcpl/capability-grant.js +1 -1
  43. package/dist/src/mcpl/capability-grant.js.map +1 -1
  44. package/dist/src/mcpl/channel-registry.d.ts +95 -18
  45. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  46. package/dist/src/mcpl/channel-registry.js +578 -150
  47. package/dist/src/mcpl/channel-registry.js.map +1 -1
  48. package/dist/src/mcpl/convert-block.d.ts +2 -2
  49. package/dist/src/mcpl/convert-block.d.ts.map +1 -1
  50. package/dist/src/mcpl/convert-block.js +6 -7
  51. package/dist/src/mcpl/convert-block.js.map +1 -1
  52. package/dist/src/mcpl/index.d.ts +5 -1
  53. package/dist/src/mcpl/index.d.ts.map +1 -1
  54. package/dist/src/mcpl/index.js +3 -1
  55. package/dist/src/mcpl/index.js.map +1 -1
  56. package/dist/src/mcpl/inference-router.d.ts +4 -2
  57. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  58. package/dist/src/mcpl/inference-router.js +6 -7
  59. package/dist/src/mcpl/inference-router.js.map +1 -1
  60. package/dist/src/mcpl/log-value.d.ts +7 -0
  61. package/dist/src/mcpl/log-value.d.ts.map +1 -1
  62. package/dist/src/mcpl/log-value.js +9 -0
  63. package/dist/src/mcpl/log-value.js.map +1 -1
  64. package/dist/src/mcpl/modern-connection.d.ts +227 -0
  65. package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
  66. package/dist/src/mcpl/modern-connection.js +1088 -0
  67. package/dist/src/mcpl/modern-connection.js.map +1 -0
  68. package/dist/src/mcpl/prefix-rule.d.ts +18 -0
  69. package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
  70. package/dist/src/mcpl/prefix-rule.js +25 -0
  71. package/dist/src/mcpl/prefix-rule.js.map +1 -0
  72. package/dist/src/mcpl/protocol-family.d.ts +179 -0
  73. package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
  74. package/dist/src/mcpl/protocol-family.js +697 -0
  75. package/dist/src/mcpl/protocol-family.js.map +1 -0
  76. package/dist/src/mcpl/push-coalescer.d.ts +38 -19
  77. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  78. package/dist/src/mcpl/push-coalescer.js +52 -23
  79. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  80. package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
  81. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  82. package/dist/src/mcpl/reference-fetcher.js +243 -76
  83. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  84. package/dist/src/mcpl/references.d.ts +63 -18
  85. package/dist/src/mcpl/references.d.ts.map +1 -1
  86. package/dist/src/mcpl/references.js +117 -32
  87. package/dist/src/mcpl/references.js.map +1 -1
  88. package/dist/src/mcpl/server-connection.d.ts +326 -30
  89. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  90. package/dist/src/mcpl/server-connection.js +995 -212
  91. package/dist/src/mcpl/server-connection.js.map +1 -1
  92. package/dist/src/mcpl/server-registry.d.ts +25 -5
  93. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  94. package/dist/src/mcpl/server-registry.js +51 -12
  95. package/dist/src/mcpl/server-registry.js.map +1 -1
  96. package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
  97. package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
  98. package/dist/src/mcpl/tool-result-normalize.js +232 -0
  99. package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
  100. package/dist/src/mcpl/transport.d.ts +56 -13
  101. package/dist/src/mcpl/transport.d.ts.map +1 -1
  102. package/dist/src/mcpl/transport.js +206 -70
  103. package/dist/src/mcpl/transport.js.map +1 -1
  104. package/dist/src/mcpl/types.d.ts +66 -20
  105. package/dist/src/mcpl/types.d.ts.map +1 -1
  106. package/dist/src/mcpl/types.js.map +1 -1
  107. package/dist/src/mcpl/visible-content.d.ts +2 -1
  108. package/dist/src/mcpl/visible-content.d.ts.map +1 -1
  109. package/dist/src/mcpl/visible-content.js +2 -1
  110. package/dist/src/mcpl/visible-content.js.map +1 -1
  111. package/dist/src/module-registry.d.ts +16 -6
  112. package/dist/src/module-registry.d.ts.map +1 -1
  113. package/dist/src/module-registry.js +65 -6
  114. package/dist/src/module-registry.js.map +1 -1
  115. package/dist/src/modules/api/index.d.ts +1 -1
  116. package/dist/src/modules/api/index.d.ts.map +1 -1
  117. package/dist/src/modules/api/index.js +3 -2
  118. package/dist/src/modules/api/index.js.map +1 -1
  119. package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
  120. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  121. package/dist/src/modules/discord/discord-js-client.js +268 -65
  122. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  123. package/dist/src/modules/discord/index.d.ts +27 -5
  124. package/dist/src/modules/discord/index.d.ts.map +1 -1
  125. package/dist/src/modules/discord/index.js +366 -76
  126. package/dist/src/modules/discord/index.js.map +1 -1
  127. package/dist/src/modules/discord/refusal.d.ts +14 -0
  128. package/dist/src/modules/discord/refusal.d.ts.map +1 -0
  129. package/dist/src/modules/discord/refusal.js +14 -0
  130. package/dist/src/modules/discord/refusal.js.map +1 -0
  131. package/dist/src/modules/discord/types.d.ts +17 -7
  132. package/dist/src/modules/discord/types.d.ts.map +1 -1
  133. package/dist/src/modules/health/index.d.ts.map +1 -1
  134. package/dist/src/modules/health/index.js +6 -2
  135. package/dist/src/modules/health/index.js.map +1 -1
  136. package/dist/src/modules/index.d.ts +1 -1
  137. package/dist/src/modules/index.d.ts.map +1 -1
  138. package/dist/src/modules/index.js +1 -1
  139. package/dist/src/modules/index.js.map +1 -1
  140. package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
  141. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  142. package/dist/src/modules/workspace/disk-agreement.js +45 -5
  143. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  144. package/dist/src/modules/workspace/effects.d.ts +2 -1
  145. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  146. package/dist/src/modules/workspace/effects.js +19 -9
  147. package/dist/src/modules/workspace/effects.js.map +1 -1
  148. package/dist/src/modules/workspace/index.d.ts +214 -42
  149. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  150. package/dist/src/modules/workspace/index.js +667 -113
  151. package/dist/src/modules/workspace/index.js.map +1 -1
  152. package/dist/src/modules/workspace/observe.d.ts +240 -8
  153. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  154. package/dist/src/modules/workspace/observe.js +554 -24
  155. package/dist/src/modules/workspace/observe.js.map +1 -1
  156. package/dist/src/modules/workspace/types.d.ts +10 -1
  157. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  158. package/dist/src/modules/workspace/types.js.map +1 -1
  159. package/dist/src/modules/workspace/watcher.d.ts +42 -0
  160. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  161. package/dist/src/modules/workspace/watcher.js +130 -25
  162. package/dist/src/modules/workspace/watcher.js.map +1 -1
  163. package/dist/src/silent-heartbeat.d.ts +112 -39
  164. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  165. package/dist/src/silent-heartbeat.js +100 -29
  166. package/dist/src/silent-heartbeat.js.map +1 -1
  167. package/dist/src/tool-args.d.ts +40 -0
  168. package/dist/src/tool-args.d.ts.map +1 -0
  169. package/dist/src/tool-args.js +207 -0
  170. package/dist/src/tool-args.js.map +1 -0
  171. package/dist/src/tool-presentation.d.ts.map +1 -1
  172. package/dist/src/tool-presentation.js +9 -2
  173. package/dist/src/tool-presentation.js.map +1 -1
  174. package/dist/src/types/agent.d.ts +27 -17
  175. package/dist/src/types/agent.d.ts.map +1 -1
  176. package/dist/src/types/events.d.ts +8 -0
  177. package/dist/src/types/events.d.ts.map +1 -1
  178. package/dist/src/types/framework.d.ts +17 -2
  179. package/dist/src/types/framework.d.ts.map +1 -1
  180. package/dist/src/types/module.d.ts +54 -10
  181. package/dist/src/types/module.d.ts.map +1 -1
  182. package/dist/src/types/trace.d.ts +2 -2
  183. package/dist/src/types/trace.d.ts.map +1 -1
  184. package/package.json +3 -1
  185. package/src/agent.ts +99 -36
  186. package/src/code-execution/index.ts +1 -0
  187. package/src/code-execution/py-runner.ts +104 -17
  188. package/src/code-execution/runtime-py.ts +47 -7
  189. package/src/code-execution/tool-definition.ts +65 -11
  190. package/src/config-checks.ts +4 -2
  191. package/src/framework.ts +3633 -956
  192. package/src/gate/event-gate.ts +14 -6
  193. package/src/held-writes.ts +59 -0
  194. package/src/index.ts +19 -4
  195. package/src/mcpl/capability-grant.ts +1 -1
  196. package/src/mcpl/channel-registry.ts +613 -160
  197. package/src/mcpl/convert-block.ts +15 -7
  198. package/src/mcpl/index.ts +14 -1
  199. package/src/mcpl/inference-router.ts +8 -7
  200. package/src/mcpl/log-value.ts +10 -0
  201. package/src/mcpl/modern-connection.ts +1182 -0
  202. package/src/mcpl/prefix-rule.ts +32 -0
  203. package/src/mcpl/protocol-family.ts +691 -0
  204. package/src/mcpl/push-coalescer.ts +67 -29
  205. package/src/mcpl/reference-fetcher.ts +288 -82
  206. package/src/mcpl/references.ts +123 -33
  207. package/src/mcpl/server-connection.ts +1050 -221
  208. package/src/mcpl/server-registry.ts +50 -12
  209. package/src/mcpl/tool-result-normalize.ts +294 -0
  210. package/src/mcpl/transport.ts +204 -62
  211. package/src/mcpl/types.ts +67 -20
  212. package/src/mcpl/visible-content.ts +2 -1
  213. package/src/module-registry.ts +68 -13
  214. package/src/modules/api/index.ts +3 -2
  215. package/src/modules/discord/discord-js-client.ts +288 -72
  216. package/src/modules/discord/index.ts +380 -77
  217. package/src/modules/discord/refusal.ts +13 -0
  218. package/src/modules/discord/types.ts +17 -9
  219. package/src/modules/health/index.ts +6 -2
  220. package/src/modules/index.ts +1 -1
  221. package/src/modules/workspace/disk-agreement.ts +46 -5
  222. package/src/modules/workspace/effects.ts +18 -7
  223. package/src/modules/workspace/index.ts +722 -124
  224. package/src/modules/workspace/observe.ts +591 -22
  225. package/src/modules/workspace/types.ts +10 -1
  226. package/src/modules/workspace/watcher.ts +126 -25
  227. package/src/silent-heartbeat.ts +184 -59
  228. package/src/tool-args.ts +213 -0
  229. package/src/tool-presentation.ts +9 -2
  230. package/src/types/agent.ts +27 -17
  231. package/src/types/events.ts +8 -0
  232. package/src/types/framework.ts +17 -2
  233. package/src/types/module.ts +55 -10
  234. package/src/types/trace.ts +2 -2
@@ -4,8 +4,6 @@ import type {
4
4
  MessageId,
5
5
  MessageMetadata,
6
6
  MessageQuery,
7
- MessageQueryResult,
8
- StoredMessage,
9
7
  ContextInjection,
10
8
  } from '@animalabs/context-manager';
11
9
  import type {
@@ -22,10 +20,14 @@ import type {
22
20
  SpeechHandlerOptions,
23
21
  ProcessEvent,
24
22
  TraceEventListener,
23
+ ModuleMessage,
24
+ MessageQueryResult,
25
25
  } from './types/index.js';
26
+ import { heldWriteId } from './held-writes.js';
26
27
  import type { Agent } from './agent.js';
27
28
  import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, waitForShutdown } from './shutdown.js';
28
29
  import { logValue } from './mcpl/log-value.js';
30
+ import { toolArgsProblems } from './tool-args.js';
29
31
 
30
32
  /** How the registry queues a module's event: its push names the module. A
31
33
  * `ProcessQueue` serves too, ignoring the name. */
@@ -111,7 +113,7 @@ export class ModuleRegistry {
111
113
  private addMessageFn: (participant: string, content: ContentBlock[], metadata?: MessageMetadata) => MessageId;
112
114
  private editMessageFn: (id: MessageId, content: ContentBlock[]) => void;
113
115
  private removeMessageFn: (id: MessageId) => void;
114
- private getMessageFn: (id: MessageId) => StoredMessage | null;
116
+ private getMessageFn: (id: MessageId) => ModuleMessage | null;
115
117
  private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
116
118
 
117
119
  private pushEventFn: (event: ProcessEvent, moduleName?: string) => void;
@@ -135,7 +137,7 @@ export class ModuleRegistry {
135
137
  addMessage: (participant: string, content: ContentBlock[], metadata?: MessageMetadata) => MessageId;
136
138
  editMessage: (id: MessageId, content: ContentBlock[]) => void;
137
139
  removeMessage: (id: MessageId) => void;
138
- getMessage: (id: MessageId) => StoredMessage | null;
140
+ getMessage: (id: MessageId) => ModuleMessage | null;
139
141
  queryMessages: (filter: MessageQuery) => MessageQueryResult;
140
142
  /** Called with the pushing module's name. */
141
143
  pushEvent: (event: ProcessEvent, moduleName?: string) => void;
@@ -300,8 +302,8 @@ export class ModuleRegistry {
300
302
  * model. The result is checked and read once, in its JSON form
301
303
  * ({@link storableToolResult}).
302
304
  */
303
- async storableToolCall(call: ToolCall): Promise<ToolResult> {
304
- return storableToolResult(call, await this.handleToolCall(call));
305
+ async storableToolCall(call: ToolCall, options: { definition?: ToolDefinition } = {}): Promise<ToolResult> {
306
+ return storableToolResult(call, await this.handleToolCall(call, options));
305
307
  }
306
308
 
307
309
  /**
@@ -309,8 +311,14 @@ export class ModuleRegistry {
309
311
  * result, unchecked, as it returned it, for a caller that uses the result
310
312
  * in process (`ModuleContext.callTool`, `executeToolCall`, or a host
311
313
  * calling the registry). A module's throw is answered as an error result.
314
+ * A call whose input isn't an object, or lacks a key the tool's schema
315
+ * requires, is answered before the module runs, with an error result that
316
+ * opens "Nothing was done:" and names what to send (toolArgsProblems).
317
+ * A caller that has already resolved the tool's definition, as the `utils`
318
+ * tool has a utility's, passes it as `definition`, and it's checked against
319
+ * that one.
312
320
  */
313
- async handleToolCall(call: ToolCall): Promise<ToolResult> {
321
+ async handleToolCall(call: ToolCall, options: { definition?: ToolDefinition } = {}): Promise<ToolResult> {
314
322
  // Parse module--tool name
315
323
  const sepIndex = call.name.indexOf('--');
316
324
  if (sepIndex === -1) {
@@ -343,6 +351,27 @@ export class ModuleRegistry {
343
351
  };
344
352
 
345
353
  try {
354
+ // The call against the tool's declared schema, before the module runs:
355
+ // a missing required argument is refused with what to send, where the
356
+ // handler would otherwise fail deep inside, or run without it. Values
357
+ // aren't checked here, since a handler may take more than its schema
358
+ // can say (toolArgsProblems). The module's own listing runs inside the
359
+ // try, so its throw is answered like any other. Utilities are listed
360
+ // only for a name the tools don't have, so a utility listing that
361
+ // throws or is slow reaches only calls to such names: a utility called
362
+ // by its own name, and a name the module answers without declaring it.
363
+ // A caller that passes `definition` lists nothing.
364
+ const definition = options.definition
365
+ ?? module.getTools().find((tool) => tool.name === toolName)
366
+ ?? module.getUtilities?.()?.find((tool) => tool.name === toolName);
367
+ const problems = toolArgsProblems(call.input, definition?.inputSchema);
368
+ if (problems.length > 0) {
369
+ return {
370
+ success: false,
371
+ error: `Nothing was done: ${call.name} ${problems.join('; ')}.`,
372
+ isError: true,
373
+ };
374
+ }
346
375
  return await module.handleToolCall(moduleCall);
347
376
  } catch (error) {
348
377
  return thrownResult(error);
@@ -583,7 +612,7 @@ class ModuleContextImpl implements ModuleContext {
583
612
  private addMessageFn: (participant: string, content: ContentBlock[], metadata?: MessageMetadata) => MessageId;
584
613
  private editMessageFn: (id: MessageId, content: ContentBlock[]) => void;
585
614
  private removeMessageFn: (id: MessageId) => void;
586
- private getMessageFn: (id: MessageId) => StoredMessage | null;
615
+ private getMessageFn: (id: MessageId) => ModuleMessage | null;
587
616
  private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
588
617
  private pushEventFn: (event: ProcessEvent, moduleName?: string) => void;
589
618
  private onTraceFn: (listener: TraceEventListener) => () => void;
@@ -603,7 +632,7 @@ class ModuleContextImpl implements ModuleContext {
603
632
  addMessage: (participant: string, content: ContentBlock[], metadata?: MessageMetadata) => MessageId,
604
633
  editMessage: (id: MessageId, content: ContentBlock[]) => void,
605
634
  removeMessage: (id: MessageId) => void,
606
- getMessage: (id: MessageId) => StoredMessage | null,
635
+ getMessage: (id: MessageId) => ModuleMessage | null,
607
636
  queryMessages: (filter: MessageQuery) => MessageQueryResult,
608
637
  pushEvent: (event: ProcessEvent, moduleName?: string) => void,
609
638
  onTrace: (listener: TraceEventListener) => () => void,
@@ -730,10 +759,13 @@ class ModuleContextImpl implements ModuleContext {
730
759
  ): MessageId {
731
760
  const id = this.addMessageFn(participant, content, metadata);
732
761
 
733
- // Track external ID if provided
762
+ // Track external ID if provided. A write the framework holds keeps '',
763
+ // as it did before held writes had handles: the map is module state an
764
+ // earlier release reads back, and a handle is no id to it.
765
+ // findMessageByExternalId finds the held write by its external id.
734
766
  if (metadata?.external) {
735
767
  const key = `${metadata.external.source}:${metadata.external.id}`;
736
- this.externalIdMap.set(key, id);
768
+ this.externalIdMap.set(key, heldWriteId(id) === undefined ? id : '');
737
769
  // Persist the mapping
738
770
  this.persistExternalIdMap();
739
771
  }
@@ -759,10 +791,33 @@ class ModuleContextImpl implements ModuleContext {
759
791
 
760
792
  findMessageByExternalId(source: string, externalId: string): MessageId | null {
761
793
  const key = `${source}:${externalId}`;
762
- return this.externalIdMap.get(key) ?? null;
794
+ const entry = this.externalIdMap.get(key);
795
+ if (entry === undefined) return null;
796
+ // A stored id answers while the store holds its row, and once the row is
797
+ // gone the entry goes, as removeMessage drops it. The '' a held write
798
+ // keeps here (addMessage) names no row, so the message is looked up in
799
+ // the store by its external id, held rows included, and the first row
800
+ // found is taken, as context-manager's findByExternalId takes it: a held
801
+ // row answers by its handle and leaves the entry as it is, and a stored
802
+ // row's id becomes the entry. With nothing found, '' stays, as earlier
803
+ // releases kept it: in a module's start(), the writes the last process
804
+ // held don't show yet, since create() restores them after the modules
805
+ // start, and a write that shows later finds its entry.
806
+ if (entry !== '') {
807
+ if (this.getMessageFn(entry)) return entry;
808
+ this.externalIdMap.delete(key);
809
+ this.persistExternalIdMap();
810
+ return null;
811
+ }
812
+ const row = this.queryMessagesFn({ source, externalIds: [externalId] }).messages[0];
813
+ if (row?.held) return row.id;
814
+ if (!row) return null;
815
+ this.externalIdMap.set(key, row.id);
816
+ this.persistExternalIdMap();
817
+ return row.id;
763
818
  }
764
819
 
765
- getMessage(id: MessageId): StoredMessage | null {
820
+ getMessage(id: MessageId): ModuleMessage | null {
766
821
  return this.getMessageFn(id);
767
822
  }
768
823
 
@@ -5,6 +5,7 @@
5
5
  * into conversation messages and inference requests.
6
6
  */
7
7
 
8
+ import { unknownToolMessage } from '../unknown-tool.js';
8
9
  import type {
9
10
  Module,
10
11
  ModuleContext,
@@ -38,9 +39,9 @@ export class ApiModule implements Module {
38
39
  // Nothing to clean up
39
40
  }
40
41
 
41
- async handleToolCall(_call: ToolCall): Promise<ToolResult> {
42
+ async handleToolCall(call: ToolCall): Promise<ToolResult> {
42
43
  // API module has no tools
43
- return { success: false, error: 'Unknown tool', isError: true };
44
+ return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
44
45
  }
45
46
 
46
47
  async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
@@ -3,11 +3,18 @@
3
3
  */
4
4
 
5
5
  import {
6
+ ChannelType,
6
7
  Client,
8
+ DiscordAPIError,
9
+ DiscordjsError,
10
+ DiscordjsErrorCodes,
11
+ DiscordjsTypeError,
7
12
  GatewayIntentBits,
8
13
  Partials,
14
+ RESTJSONErrorCodes,
9
15
  TextChannel,
10
16
  DMChannel,
17
+ type Channel,
11
18
  type Message,
12
19
  type Attachment,
13
20
  type User,
@@ -22,6 +29,92 @@ import type {
22
29
  DiscordChannel,
23
30
  HistoryMessage,
24
31
  } from './types.js';
32
+ import { DiscordRefusal } from './refusal.js';
33
+
34
+ /** The ids a tool's call named, for the words of Discord's refusal. */
35
+ interface Named {
36
+ channelId?: string;
37
+ messageId?: string;
38
+ guildId?: string;
39
+ userId?: string;
40
+ emoji?: string;
41
+ }
42
+
43
+ /** What a refused call was about, from the ids it named. */
44
+ function target(named: Named): string {
45
+ return named.channelId !== undefined ? `channel ${named.channelId}`
46
+ : named.guildId !== undefined ? `server ${named.guildId}`
47
+ : `user ${named.userId}`;
48
+ }
49
+
50
+ /**
51
+ * Discord's refusal of a request, worded for the agent: what was refused, and
52
+ * the call that shows the right id, or who can change what Discord refused.
53
+ * The codes are discord-api-types' RESTJSONErrorCodes, as discord.js 14.27
54
+ * exports them. A code without a case of its own keeps Discord's own words.
55
+ * `overrides` words a code as one tool means it.
56
+ */
57
+ function refusalReason(error: DiscordAPIError, named: Named, overrides: Partial<Record<number, string>> = {}): string {
58
+ const override = typeof error.code === 'number' ? overrides[error.code] : undefined;
59
+ if (override !== undefined) return override;
60
+ switch (error.code) {
61
+ case RESTJSONErrorCodes.UnknownChannel:
62
+ return `no ${target(named)} is visible to this bot. The id may be mistyped or remembered rather than copied; discord--list_guilds, then discord--list_channels with a guildId, shows the ids. Threads aren't listed there: a thread started from a message has that message's id, which discord--fetch_history of its channel shows. Nor are direct message channels: one's id is the channelId an earlier discord--dm to that user returned.`;
63
+ case RESTJSONErrorCodes.UnknownGuild:
64
+ return `no server ${named.guildId} is visible to this bot. discord--list_guilds shows the servers it is in, with their ids.`;
65
+ case RESTJSONErrorCodes.UnknownMessage:
66
+ return `channel ${named.channelId} has no message ${named.messageId} that this bot can see. discord--fetch_history with that channelId shows its recent messages and their ids.`;
67
+ case RESTJSONErrorCodes.UnknownUser:
68
+ return `Discord knows no user ${named.userId}. The id may be mistyped or remembered rather than copied; discord--fetch_history of a channel they wrote in shows its messages' authors' ids.`;
69
+ case RESTJSONErrorCodes.UnknownEmoji:
70
+ return unknownEmoji(named);
71
+ case RESTJSONErrorCodes.MissingAccess:
72
+ return `this bot can't see ${target(named)} (Discord answered "Missing Access"), and calling again won't change that. `
73
+ + 'Ask whoever runs this host to give it access.';
74
+ case RESTJSONErrorCodes.MissingPermissions:
75
+ return `this bot isn't allowed to do that${named.channelId !== undefined || named.guildId !== undefined ? ` in ${target(named)}` : ''} (Discord answered "Missing Permissions"), and calling again won't change that. `
76
+ + 'Ask whoever runs this host to grant the permission.';
77
+ case RESTJSONErrorCodes.CannotEditMessageAuthoredByAnotherUser:
78
+ return `message ${named.messageId} was written by someone else, and this bot can edit only its own messages.`;
79
+ case RESTJSONErrorCodes.CannotSendMessagesToThisUser:
80
+ return `Discord won't deliver a direct message from this bot ${named.userId !== undefined ? `to user ${named.userId}` : `in channel ${named.channelId}`}, usually because they share no server or don't accept direct messages, and calling again won't change that.`;
81
+ default:
82
+ return `Discord refused the request: ${error.message}`;
83
+ }
84
+ }
85
+
86
+ /** Discord starts a thread from a message only in a server's text or announcement channel; a forum's or media channel's threads are its posts. */
87
+ function cantHoldThreads(named: Named): string {
88
+ return `${target(named)} can't hold threads. A message can start one only in a server's text or announcement channel.`;
89
+ }
90
+
91
+ /** A message that has a thread: discord.js finds a message's thread by the message's own id (Message#thread). */
92
+ function existingThread(named: Named): string {
93
+ return `message ${named.messageId} already has a thread, whose id is the message's own: discord--send with channelId ${named.messageId} posts in it.`;
94
+ }
95
+
96
+ function unknownEmoji(named: Named): string {
97
+ return `Discord doesn't know the emoji ${JSON.stringify(named.emoji)}. Use a Unicode emoji, such as "👍", or a custom one as name:id from a server this bot is in.`;
98
+ }
99
+
100
+ /**
101
+ * discord.js's own refusal of a call, before it sends any request: a message
102
+ * whose channel can't hold threads, one that already has a thread, or an
103
+ * emoji it can't read. Any other error has no words here.
104
+ */
105
+ function localRefusalReason(error: unknown, named: Named): string | undefined {
106
+ if (!(error instanceof DiscordjsError) && !(error instanceof DiscordjsTypeError)) return undefined;
107
+ switch ((error as { code?: unknown }).code) {
108
+ case DiscordjsErrorCodes.MessageThreadParent:
109
+ return cantHoldThreads(named);
110
+ case DiscordjsErrorCodes.MessageExistingThread:
111
+ return existingThread(named);
112
+ case DiscordjsErrorCodes.EmojiType:
113
+ return `this bot can't read ${JSON.stringify(named.emoji)} as an emoji, so Discord wasn't asked. Use a Unicode emoji, such as "👍", or a custom one as name:id from a server this bot is in.`;
114
+ default:
115
+ return undefined;
116
+ }
117
+ }
25
118
 
26
119
  export interface DiscordJsClientConfig {
27
120
  token: string;
@@ -47,7 +140,7 @@ export class DiscordJsClient implements DiscordClientInterface {
47
140
  private channelIds?: string[];
48
141
 
49
142
  private messageHandler?: (message: DiscordMessageData) => void;
50
- private editHandler?: (messageId: string, newContent: string) => void;
143
+ private editHandler?: (messageId: string, newContent: string, attachments?: DiscordAttachment[]) => void;
51
144
  private deleteHandler?: (messageId: string) => void;
52
145
  private readyHandler?: () => void;
53
146
 
@@ -78,13 +171,18 @@ export class DiscordJsClient implements DiscordClientInterface {
78
171
  }
79
172
  });
80
173
 
81
- this.client.on('messageUpdate', (_oldMessage, newMessage) => {
174
+ this.client.on('messageUpdate', (oldMessage, newMessage) => {
82
175
  // content is null for a partial message, one discord.js hadn't cached
83
176
  // and whose update carried no text, so there's no text to compare; a
84
177
  // cached message keeps the text it had. An edit that leaves no text,
85
178
  // beside an attachment, is '' and is still an edit.
86
179
  if (typeof newMessage.content !== 'string' || !this.editHandler) return;
87
- this.editHandler(newMessage.id, newMessage.content);
180
+ // Its attachments as the edit left them, for a message discord.js had
181
+ // cached: it takes the update's list when the update carries one and
182
+ // keeps the cached list when it doesn't. A message it hadn't cached
183
+ // starts with an empty list, so one whose update carried none would read
184
+ // as having none; it passes none, and the row's blocks stay.
185
+ this.editHandler(newMessage.id, newMessage.content, oldMessage.partial ? undefined : newMessage.attachments?.map(toAttachment));
88
186
  });
89
187
 
90
188
  this.client.on('messageDelete', (message) => {
@@ -169,35 +267,69 @@ export class DiscordJsClient implements DiscordClientInterface {
169
267
  channelId: string,
170
268
  content: string,
171
269
  options?: { replyTo?: string; createThread?: { name: string } }
172
- ): Promise<{ messageId: string; threadFailure?: string }> {
173
- const channel = await this.client.channels.fetch(channelId);
174
- if (!channel || !('send' in channel)) {
175
- throw new Error(`Channel ${channelId} not found or not a text channel`);
176
- }
177
-
178
- const textChannel = channel as TextChannel | DMChannel;
179
- const message = await textChannel.send({
180
- content,
181
- reply: options?.replyTo ? { messageReference: options.replyTo } : undefined,
270
+ ): Promise<{ messageId: string; threadFailure?: string; note?: string }> {
271
+ const lead = 'Nothing was sent';
272
+ const { textChannel, message } = await this.asked(lead, { channelId }, async () => {
273
+ const textChannel = await this.lookup(lead, `channel ${channelId}`, () => this.messageChannel(channelId, lead, 'send')) as TextChannel | DMChannel;
274
+ const message = await textChannel.send({
275
+ content,
276
+ reply: options?.replyTo ? { messageReference: options.replyTo } : undefined,
277
+ });
278
+ return { textChannel, message };
182
279
  });
183
280
 
184
- if (options?.createThread && 'threads' in textChannel) {
185
- try {
186
- await message.startThread({ name: options.createThread.name });
187
- } catch (error) {
188
- // The message is out, so the caller counts it as sent before it
189
- // reports why its thread wasn't started.
190
- return { messageId: message.id, threadFailure: error instanceof Error ? error.message : String(error) };
191
- }
281
+ if (!options?.createThread) return { messageId: message.id };
282
+ // A channel without threads, such as a DM, gets none, and the send
283
+ // succeeds, as it did; its note says so.
284
+ if (!('threads' in textChannel)) {
285
+ return { messageId: message.id, note: `No thread was started: ${cantHoldThreads({ channelId })}` };
192
286
  }
287
+ const threadFailure = await this.threadAfterSend(message, channelId, options.createThread.name);
288
+ return threadFailure === undefined ? { messageId: message.id } : { messageId: message.id, threadFailure };
289
+ }
193
290
 
194
- return { messageId: message.id };
291
+ /**
292
+ * Starts the thread a send asked for. The message is out by then, so when
293
+ * the thread isn't started, why is returned rather than thrown, in words
294
+ * that say the message was sent, lest the agent send it again.
295
+ */
296
+ private async threadAfterSend(message: Message, channelId: string, name: string): Promise<string | undefined> {
297
+ const sent = `The message was sent (id ${message.id}), so don't send it again`;
298
+ const named = { channelId, messageId: message.id };
299
+ const startOne = `discord--create_thread with channelId ${channelId}, messageId ${message.id} and a name`;
300
+ try {
301
+ await message.startThread({ name });
302
+ return undefined;
303
+ } catch (error) {
304
+ if (error instanceof DiscordAPIError) {
305
+ // Discord answers a retried start that it had already carried out as
306
+ // a thread that exists: say it's there.
307
+ if (error.code === RESTJSONErrorCodes.ThreadAlreadyCreatedForMessage) return `${sent}, and its thread exists already, with the message's own id, ${message.id}.`;
308
+ // The message is out, and `named` holds it, so no sentence points
309
+ // elsewhere, where posting it again would post it twice.
310
+ // The bot has just posted in the channel, so Missing Access is about
311
+ // its threads, not about seeing it.
312
+ const permission = `this bot isn't allowed to start threads in channel ${channelId} (Discord answered "Missing Permissions"), and calling again won't change that. Ask whoever runs this host to grant the permission.`;
313
+ const access = `this bot can't start threads in channel ${channelId} (Discord answered "Missing Access"), and calling again won't change that. Ask whoever runs this host to give it access.`;
314
+ return `${sent}, but no thread was started: ${refusalReason(error, named, { [RESTJSONErrorCodes.MissingPermissions]: permission, [RESTJSONErrorCodes.MissingAccess]: access })} Once that's settled, ${startOne} starts one.`;
315
+ }
316
+ // The thread refusals localRefusalReason words, a message outside a
317
+ // text or announcement channel and one that has a thread, can't come
318
+ // for a message just sent in a channel that holds threads.
319
+ return `${sent}, but starting its thread stopped on an unexpected error: ${error instanceof Error ? error.message : String(error)}. ` +
320
+ `Whether the thread exists is unknown: discord--fetch_history with channelId ${message.id} reads the thread if it exists and is refused if it doesn't, and then ${startOne} starts one.`;
321
+ }
195
322
  }
196
323
 
197
- async sendDM(userId: string, content: string): Promise<{ messageId: string }> {
198
- const user = await this.client.users.fetch(userId);
199
- const message = await user.send(content);
200
- return { messageId: message.id };
324
+ async sendDM(userId: string, content: string): Promise<{ messageId: string; channelId: string }> {
325
+ const lead = 'Nothing was sent';
326
+ return this.asked(lead, { userId }, async () => {
327
+ const user = await this.lookup(lead, `user ${userId}`, () => this.client.users.fetch(userId));
328
+ // User#send opens the direct message channel first, which sends nothing.
329
+ const channel = await this.lookup(lead, `the direct message channel with user ${userId}`, () => user.createDM());
330
+ const message = await channel.send(content);
331
+ return { messageId: message.id, channelId: message.channelId };
332
+ });
201
333
  }
202
334
 
203
335
  async fetchMessage(channelId: string, messageId: string): Promise<string> {
@@ -210,30 +342,36 @@ export class DiscordJsClient implements DiscordClientInterface {
210
342
  }
211
343
 
212
344
  async editMessage(channelId: string, messageId: string, content: string): Promise<void> {
213
- const channel = await this.client.channels.fetch(channelId);
214
- if (!channel || !('messages' in channel)) {
215
- throw new Error(`Channel ${channelId} not found`);
216
- }
217
- const message = await (channel as TextChannel).messages.fetch(messageId);
218
- await message.edit(content);
345
+ const lead = 'Nothing was edited';
346
+ await this.asked(lead, { channelId, messageId }, async () => {
347
+ const message = await this.findMessage(channelId, messageId, lead);
348
+ await message.edit(content);
349
+ });
219
350
  }
220
351
 
221
352
  async deleteMessage(channelId: string, messageId: string): Promise<void> {
222
- const channel = await this.client.channels.fetch(channelId);
223
- if (!channel || !('messages' in channel)) {
224
- throw new Error(`Channel ${channelId} not found`);
353
+ const lead = 'Nothing was deleted';
354
+ try {
355
+ await this.asked(lead, { channelId, messageId }, async () => {
356
+ const message = await this.findMessage(channelId, messageId, lead);
357
+ await message.delete();
358
+ });
359
+ } catch (error) {
360
+ // A message that isn't there may be one an earlier call deleted, or a
361
+ // retried request of this one, so its sentence has no lead.
362
+ if (error instanceof DiscordRefusal && error.cause instanceof DiscordAPIError && error.cause.code === RESTJSONErrorCodes.UnknownMessage) {
363
+ throw new DiscordRefusal(`Channel ${channelId} has no message ${messageId} now: it's deleted, or the id is wrong. discord--fetch_history with that channelId shows its recent messages and their ids.`, { cause: error.cause });
364
+ }
365
+ throw error;
225
366
  }
226
- const message = await (channel as TextChannel).messages.fetch(messageId);
227
- await message.delete();
228
367
  }
229
368
 
230
369
  async addReaction(channelId: string, messageId: string, emoji: string): Promise<void> {
231
- const channel = await this.client.channels.fetch(channelId);
232
- if (!channel || !('messages' in channel)) {
233
- throw new Error(`Channel ${channelId} not found`);
234
- }
235
- const message = await (channel as TextChannel).messages.fetch(messageId);
236
- await message.react(emoji);
370
+ const lead = 'No reaction was added';
371
+ await this.asked(lead, { channelId, messageId, emoji }, async () => {
372
+ const message = await this.findMessage(channelId, messageId, lead);
373
+ await message.react(emoji);
374
+ });
237
375
  }
238
376
 
239
377
  async createThread(
@@ -242,23 +380,108 @@ export class DiscordJsClient implements DiscordClientInterface {
242
380
  name: string,
243
381
  autoArchiveDuration?: number
244
382
  ): Promise<{ threadId: string }> {
383
+ const lead = 'No thread was started';
384
+ try {
385
+ return await this.asked(lead, { channelId, messageId }, async () => {
386
+ const message = await this.findMessage(channelId, messageId, lead);
387
+ const thread = await message.startThread({
388
+ name,
389
+ autoArchiveDuration: (autoArchiveDuration as 60 | 1440 | 4320 | 10080) ?? 1440,
390
+ });
391
+ return { threadId: thread.id };
392
+ });
393
+ } catch (error) {
394
+ // Discord's answer for a message that has a thread, whether an earlier
395
+ // call started it or a retried request of this one did.
396
+ if (error instanceof DiscordRefusal && error.cause instanceof DiscordAPIError && error.cause.code === RESTJSONErrorCodes.ThreadAlreadyCreatedForMessage) {
397
+ throw new DiscordRefusal(`Message ${messageId} has a thread already, whose id is the message's own: discord--send with channelId ${messageId} posts in it.`, { cause: error.cause });
398
+ }
399
+ throw error;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * The message a tool acts on, looked up in its channel. An empty id is
405
+ * refused before any request: discord.js fetches a channel's recent
406
+ * messages for one, not a message.
407
+ */
408
+ private findMessage(channelId: string, messageId: string, lead: string): Promise<Message> {
409
+ if (!messageId) {
410
+ return Promise.reject(new DiscordRefusal(`${lead}: the call gave no message id. discord--fetch_history with channelId ${channelId} shows its recent messages and their ids.`));
411
+ }
412
+ return this.lookup(lead, `message ${messageId} in channel ${channelId}`,
413
+ async () => (await this.messageChannel(channelId, lead, 'messages')).messages.fetch(messageId));
414
+ }
415
+
416
+ /**
417
+ * Runs a lookup a tool makes before its change. A lookup sends, edits and
418
+ * removes nothing, so one that stops on an unexpected error, such as a
419
+ * network failure, means the tool did nothing, which a refusal led by
420
+ * `lead` says, and calling it again is safe: opening a direct message
421
+ * channel, the one lookup that can make something on Discord, opens the
422
+ * same channel again. Discord refusing it, or a refusal of this module's
423
+ * own, passes on as it is; anything else, discord.js's own errors
424
+ * included, is the unexpected stop.
425
+ */
426
+ private async lookup<T>(lead: string, what: string, run: () => Promise<T>): Promise<T> {
427
+ try {
428
+ return await run();
429
+ } catch (error) {
430
+ if (error instanceof DiscordAPIError || error instanceof DiscordRefusal) throw error;
431
+ throw new DiscordRefusal(`${lead}: looking up ${what} stopped on an unexpected error: ${error instanceof Error ? error.message : String(error)}. Calling it again is safe.`, { cause: error });
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Runs one tool's requests. Discord refusing any of them, which discord.js
437
+ * reports as a DiscordAPIError, becomes a DiscordRefusal led by `lead`, with
438
+ * Discord's error as its cause: a tool's change is its last request, so it
439
+ * wasn't made. So does discord.js refusing a call before it sends one
440
+ * (localRefusalReason). Anything else passes through as an unexpected error.
441
+ */
442
+ private async asked<T>(lead: string, named: Named, run: () => Promise<T>): Promise<T> {
443
+ try {
444
+ return await run();
445
+ } catch (error) {
446
+ if (error instanceof DiscordAPIError) throw new DiscordRefusal(`${lead}: ${refusalReason(error, named)}`, { cause: error });
447
+ const local = localRefusalReason(error, named);
448
+ if (local !== undefined) throw new DiscordRefusal(`${lead}: ${local}`, { cause: error });
449
+ throw error;
450
+ }
451
+ }
452
+
453
+ /**
454
+ * The channel `channelId` names, when it holds messages (and, with 'send',
455
+ * takes them). A channel Discord knows but that holds none, such as a
456
+ * category, is refused with what it is.
457
+ */
458
+ private async messageChannel(channelId: string, lead: string, need: 'messages' | 'send'): Promise<TextChannel> {
245
459
  const channel = await this.client.channels.fetch(channelId);
246
- if (!channel || !('messages' in channel)) {
247
- throw new Error(`Channel ${channelId} not found`);
460
+ if (!channel || !(need in channel)) {
461
+ throw new DiscordRefusal(`${lead}: ${this.holdsNoMessages(channelId, channel)}`);
248
462
  }
249
- const message = await (channel as TextChannel).messages.fetch(messageId);
250
- const thread = await message.startThread({
251
- name,
252
- autoArchiveDuration: (autoArchiveDuration as 60 | 1440 | 4320 | 10080) ?? 1440,
253
- });
254
- return { threadId: thread.id };
463
+ return channel as TextChannel;
464
+ }
465
+
466
+ private holdsNoMessages(channelId: string, channel: Channel | null): string {
467
+ if (!channel) {
468
+ return `this bot can't use channel ${channelId}: Discord answered for it, but it's a kind of channel this bot doesn't handle, or its server isn't loaded here, and calling again won't change that. Ask whoever runs this host if you need it.`;
469
+ }
470
+ const type = this.mapChannelType(channel.type);
471
+ const inPosts = (kind: string) => `${kind}, whose messages are in its posts, each a thread with its own id, which this module has no tool to list`;
472
+ const is = type === 'category' ? 'a category, which holds channels, not messages'
473
+ : type === 'forum' ? inPosts('a forum')
474
+ : channel.type === ChannelType.GuildMedia ? inPosts('a media channel')
475
+ : type === 'unknown' ? 'a kind of channel that holds no messages'
476
+ : `a ${type} channel, which holds no messages`;
477
+ return `channel ${channelId} is ${is}. discord--list_channels with a guildId shows the server's channels and their ids.`;
255
478
  }
256
479
 
257
480
  onMessage(handler: (message: DiscordMessageData) => void): void {
258
481
  this.messageHandler = handler;
259
482
  }
260
483
 
261
- onMessageEdit(handler: (messageId: string, newContent: string) => void): void {
484
+ onMessageEdit(handler: (messageId: string, newContent: string, attachments?: DiscordAttachment[]) => void): void {
262
485
  this.editHandler = handler;
263
486
  }
264
487
 
@@ -293,17 +516,12 @@ export class DiscordJsClient implements DiscordClientInterface {
293
516
  }
294
517
 
295
518
  async listChannels(guildId: string): Promise<DiscordChannel[]> {
296
- const guild = await this.client.guilds.fetch(guildId);
297
- if (!guild) {
298
- throw new Error(`Guild ${guildId} not found`);
299
- }
300
-
301
- // guild.channels might be undefined if the guild was fetched without full data
302
- if (!guild.channels) {
303
- throw new Error(`Guild ${guildId} channels not available - may need to re-fetch with force`);
304
- }
305
-
306
- const channels = await guild.channels.fetch();
519
+ // An unknown or unreachable server is Discord's refusal (fetch throws);
520
+ // a fetched Guild always carries its channel manager.
521
+ const channels = await this.asked('Nothing was listed', { guildId }, async () => {
522
+ const guild = await this.client.guilds.fetch(guildId);
523
+ return guild.channels.fetch();
524
+ });
307
525
  const result: DiscordChannel[] = [];
308
526
 
309
527
  channels.forEach((c: GuildBasedChannel | null) => {
@@ -339,15 +557,13 @@ export class DiscordJsClient implements DiscordClientInterface {
339
557
  channelId: string,
340
558
  options?: { limit?: number; before?: string }
341
559
  ): Promise<HistoryMessage[]> {
342
- const channel = await this.client.channels.fetch(channelId);
343
- if (!channel || !('messages' in channel)) {
344
- throw new Error(`Channel ${channelId} not found or not a text channel`);
345
- }
346
-
347
- const textChannel = channel as TextChannel;
348
- const messages = await textChannel.messages.fetch({
349
- limit: options?.limit ?? 50,
350
- before: options?.before,
560
+ const lead = 'No history was read';
561
+ const messages = await this.asked(lead, { channelId, messageId: options?.before }, async () => {
562
+ const textChannel = await this.messageChannel(channelId, lead, 'messages');
563
+ return textChannel.messages.fetch({
564
+ limit: options?.limit ?? 50,
565
+ before: options?.before,
566
+ });
351
567
  });
352
568
 
353
569
  return messages.map((m: Message) => ({