@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.
- package/README.md +105 -3
- package/dist/src/agent.d.ts +36 -10
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +76 -32
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/index.d.ts +1 -1
- package/dist/src/code-execution/index.d.ts.map +1 -1
- package/dist/src/code-execution/index.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +34 -2
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +72 -16
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +3 -2
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +47 -7
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +12 -7
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +59 -11
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/config-checks.d.ts +2 -2
- package/dist/src/config-checks.d.ts.map +1 -1
- package/dist/src/config-checks.js +6 -2
- package/dist/src/config-checks.js.map +1 -1
- package/dist/src/framework.d.ts +744 -120
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3546 -906
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +13 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/held-writes.d.ts +16 -0
- package/dist/src/held-writes.d.ts.map +1 -0
- package/dist/src/held-writes.js +56 -0
- package/dist/src/held-writes.js.map +1 -0
- package/dist/src/index.d.ts +7 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -4
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +4 -0
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +1 -1
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +95 -18
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +578 -150
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts +2 -2
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +6 -7
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -1
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +4 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +6 -7
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/log-value.d.ts +7 -0
- package/dist/src/mcpl/log-value.d.ts.map +1 -1
- package/dist/src/mcpl/log-value.js +9 -0
- package/dist/src/mcpl/log-value.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +227 -0
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -0
- package/dist/src/mcpl/modern-connection.js +1088 -0
- package/dist/src/mcpl/modern-connection.js.map +1 -0
- package/dist/src/mcpl/prefix-rule.d.ts +18 -0
- package/dist/src/mcpl/prefix-rule.d.ts.map +1 -0
- package/dist/src/mcpl/prefix-rule.js +25 -0
- package/dist/src/mcpl/prefix-rule.js.map +1 -0
- package/dist/src/mcpl/protocol-family.d.ts +179 -0
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -0
- package/dist/src/mcpl/protocol-family.js +697 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -0
- package/dist/src/mcpl/push-coalescer.d.ts +38 -19
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +52 -23
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +66 -15
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +243 -76
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +63 -18
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +117 -32
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +326 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +995 -212
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +25 -5
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +51 -12
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.d.ts +54 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.js +232 -0
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +56 -13
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +206 -70
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -20
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +2 -1
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +2 -1
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +16 -6
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +65 -6
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/api/index.d.ts +1 -1
- package/dist/src/modules/api/index.d.ts.map +1 -1
- package/dist/src/modules/api/index.js +3 -2
- package/dist/src/modules/api/index.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts +42 -2
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +268 -65
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +27 -5
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +366 -76
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/discord/refusal.d.ts +14 -0
- package/dist/src/modules/discord/refusal.d.ts.map +1 -0
- package/dist/src/modules/discord/refusal.js +14 -0
- package/dist/src/modules/discord/refusal.js.map +1 -0
- package/dist/src/modules/discord/types.d.ts +17 -7
- package/dist/src/modules/discord/types.d.ts.map +1 -1
- package/dist/src/modules/health/index.d.ts.map +1 -1
- package/dist/src/modules/health/index.js +6 -2
- package/dist/src/modules/health/index.js.map +1 -1
- package/dist/src/modules/index.d.ts +1 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +25 -2
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +45 -5
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +2 -1
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +19 -9
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +214 -42
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +667 -113
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +240 -8
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +554 -24
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +10 -1
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +42 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +130 -25
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +112 -39
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +100 -29
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/tool-args.d.ts +40 -0
- package/dist/src/tool-args.d.ts.map +1 -0
- package/dist/src/tool-args.js +207 -0
- package/dist/src/tool-args.js.map +1 -0
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +9 -2
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -17
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +8 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +17 -2
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +54 -10
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/agent.ts +99 -36
- package/src/code-execution/index.ts +1 -0
- package/src/code-execution/py-runner.ts +104 -17
- package/src/code-execution/runtime-py.ts +47 -7
- package/src/code-execution/tool-definition.ts +65 -11
- package/src/config-checks.ts +4 -2
- package/src/framework.ts +3633 -956
- package/src/gate/event-gate.ts +14 -6
- package/src/held-writes.ts +59 -0
- package/src/index.ts +19 -4
- package/src/mcpl/capability-grant.ts +1 -1
- package/src/mcpl/channel-registry.ts +613 -160
- package/src/mcpl/convert-block.ts +15 -7
- package/src/mcpl/index.ts +14 -1
- package/src/mcpl/inference-router.ts +8 -7
- package/src/mcpl/log-value.ts +10 -0
- package/src/mcpl/modern-connection.ts +1182 -0
- package/src/mcpl/prefix-rule.ts +32 -0
- package/src/mcpl/protocol-family.ts +691 -0
- package/src/mcpl/push-coalescer.ts +67 -29
- package/src/mcpl/reference-fetcher.ts +288 -82
- package/src/mcpl/references.ts +123 -33
- package/src/mcpl/server-connection.ts +1050 -221
- package/src/mcpl/server-registry.ts +50 -12
- package/src/mcpl/tool-result-normalize.ts +294 -0
- package/src/mcpl/transport.ts +204 -62
- package/src/mcpl/types.ts +67 -20
- package/src/mcpl/visible-content.ts +2 -1
- package/src/module-registry.ts +68 -13
- package/src/modules/api/index.ts +3 -2
- package/src/modules/discord/discord-js-client.ts +288 -72
- package/src/modules/discord/index.ts +380 -77
- package/src/modules/discord/refusal.ts +13 -0
- package/src/modules/discord/types.ts +17 -9
- package/src/modules/health/index.ts +6 -2
- package/src/modules/index.ts +1 -1
- package/src/modules/workspace/disk-agreement.ts +46 -5
- package/src/modules/workspace/effects.ts +18 -7
- package/src/modules/workspace/index.ts +722 -124
- package/src/modules/workspace/observe.ts +591 -22
- package/src/modules/workspace/types.ts +10 -1
- package/src/modules/workspace/watcher.ts +126 -25
- package/src/silent-heartbeat.ts +184 -59
- package/src/tool-args.ts +213 -0
- package/src/tool-presentation.ts +9 -2
- package/src/types/agent.ts +27 -17
- package/src/types/events.ts +8 -0
- package/src/types/framework.ts +17 -2
- package/src/types/module.ts +55 -10
- package/src/types/trace.ts +2 -2
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { formatZonedDateTime, resolveTimeZone } from '../../timezone.js';
|
|
10
10
|
import { logValue } from '../../mcpl/log-value.js';
|
|
11
|
+
import { unknownToolMessage } from '../unknown-tool.js';
|
|
12
|
+
import { DiscordRefusal } from './refusal.js';
|
|
11
13
|
export * from './types.js';
|
|
12
14
|
export { DiscordJsClient } from './discord-js-client.js';
|
|
15
|
+
export { DiscordRefusal } from './refusal.js';
|
|
13
16
|
const DEFAULT_CONFIG = {
|
|
14
17
|
handleDMs: true,
|
|
15
18
|
ignoreBots: true,
|
|
@@ -56,10 +59,52 @@ const TEXT_FILE_EXTENSIONS = [
|
|
|
56
59
|
'.html', '.css', '.xml', '.csv', '.log', '.sh', '.bash',
|
|
57
60
|
];
|
|
58
61
|
const CONVERSATION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
62
|
+
/** A text attachment this many bytes or larger isn't read into the agent's context. */
|
|
63
|
+
const TEXT_FILE_LIMIT_BYTES = 100_000;
|
|
64
|
+
/** activate's and deactivate's answer before the module has started, or once it has stopped. */
|
|
65
|
+
const NOT_RUNNING = "Speech handling wasn't changed: the Discord module hasn't started on this host, or has stopped. Calling it again won't change that until it runs; ask whoever runs this host if you need Discord.";
|
|
59
66
|
/** What the agent reads for a text attachment whose contents couldn't be
|
|
60
67
|
* fetched: by an error, or by an answer that wasn't OK. */
|
|
61
68
|
function attachmentNotFetched(filename) {
|
|
62
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
type: 'text',
|
|
71
|
+
text: `[Attachment: ${filename} couldn't be fetched, so its text isn't in your context. If you need it, ask the sender in the channel to paste it.]`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* What the agent reads when a tool stops on an error that isn't a refusal
|
|
76
|
+
* (DiscordRefusal), such as a network failure: its outcome is unknown. A send
|
|
77
|
+
* repeated blindly can post twice, so those say how to check; the other tools
|
|
78
|
+
* are safe to repeat, since a second call changes nothing more.
|
|
79
|
+
*/
|
|
80
|
+
function unexpectedFailure(call, error) {
|
|
81
|
+
const stopped = `discord--${call.name} stopped on an unexpected error: ${errorText(error)}.`;
|
|
82
|
+
const channelId = call.input?.channelId;
|
|
83
|
+
switch (call.name) {
|
|
84
|
+
case 'send':
|
|
85
|
+
return `${stopped} Whether the message was sent is unknown: discord--fetch_history with channelId ${String(channelId)} shows it, so check before you send it again.`;
|
|
86
|
+
case 'dm':
|
|
87
|
+
return `${stopped} Whether the message was sent is unknown. If an earlier discord--dm to this user gave you its channelId, discord--fetch_history with it shows whether this one went; without it, sending it again may send it twice.`;
|
|
88
|
+
case 'edit':
|
|
89
|
+
case 'react':
|
|
90
|
+
case 'delete':
|
|
91
|
+
case 'create_thread':
|
|
92
|
+
return `${stopped} Whether it took effect is unknown. Calling it again is safe: if it did, the second call changes nothing more.`;
|
|
93
|
+
case 'list_guilds':
|
|
94
|
+
case 'list_channels':
|
|
95
|
+
case 'fetch_history':
|
|
96
|
+
return `${stopped} It changes nothing on Discord, so you can call it again.`;
|
|
97
|
+
default:
|
|
98
|
+
return `${stopped} Calling it again is safe.`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** An error's own words, for a sentence about it. */
|
|
102
|
+
function errorText(error) {
|
|
103
|
+
return error instanceof Error ? error.message : String(error);
|
|
104
|
+
}
|
|
105
|
+
/** A count of seconds, for the agent's eyes. */
|
|
106
|
+
function seconds(n) {
|
|
107
|
+
return `${n} second${n === 1 ? '' : 's'}`;
|
|
63
108
|
}
|
|
64
109
|
/** What a stored message reads when it has no text of its own and nothing the
|
|
65
110
|
* host added, so the agent still sees that a message was there. */
|
|
@@ -172,6 +217,65 @@ function holdsNotFetched(content, message) {
|
|
|
172
217
|
const notes = new Set((message.attachments ?? []).map((attachment) => attachmentNotFetched(attachment.filename).text));
|
|
173
218
|
return content.slice(1).some((block) => block.type === 'text' && notes.has(block.text));
|
|
174
219
|
}
|
|
220
|
+
/** A URL without its query or fragment: Discord's CDN signs an attachment's
|
|
221
|
+
* URL afresh in each payload, so only its path names the attachment. */
|
|
222
|
+
function urlPath(url) {
|
|
223
|
+
return url.split(/[?#]/)[0] ?? url;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Whether a block the host added for an attachment stands for none of
|
|
227
|
+
* `attachments`, matched as the live conversion writes it: an image by its
|
|
228
|
+
* URL, a file's text by its header, and a note by the filename it opens
|
|
229
|
+
* with, as every note for an attachment does, whatever its words after it.
|
|
230
|
+
* A block that stands for no attachment, such as EMPTY_MESSAGE, never does.
|
|
231
|
+
*/
|
|
232
|
+
function forGoneAttachment(block, attachments) {
|
|
233
|
+
if (block.type === 'image') {
|
|
234
|
+
const source = block.source;
|
|
235
|
+
if (source?.type !== 'url' || typeof source.url !== 'string')
|
|
236
|
+
return false;
|
|
237
|
+
const path = urlPath(source.url);
|
|
238
|
+
return !attachments.some((attachment) => urlPath(attachment.url) === path);
|
|
239
|
+
}
|
|
240
|
+
if (block.type !== 'text')
|
|
241
|
+
return false;
|
|
242
|
+
const { text } = block;
|
|
243
|
+
const opening = text.startsWith('\n--- File: ') ? (filename) => `\n--- File: ${filename} ---\n`
|
|
244
|
+
: text.startsWith('[Attachment: ') ? (filename) => `[Attachment: ${filename} `
|
|
245
|
+
: null;
|
|
246
|
+
return opening !== null && !attachments.some((attachment) => text.startsWith(opening(attachment.filename)));
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* A text-first row's content without the blocks the host added for
|
|
250
|
+
* attachments its message no longer has, or `content` itself when none is
|
|
251
|
+
* gone. A block stays while an attachment it may stand for remains, so of two
|
|
252
|
+
* files of one name, removing one keeps both blocks, and a block that names
|
|
253
|
+
* no attachment stays. EMPTY_MESSAGE comes back once nothing is left.
|
|
254
|
+
*/
|
|
255
|
+
function withoutGoneAttachments(content, attachments) {
|
|
256
|
+
const [first, ...rest] = content;
|
|
257
|
+
if (first?.type !== 'text')
|
|
258
|
+
return content;
|
|
259
|
+
const kept = rest.filter((block) => !forGoneAttachment(block, attachments));
|
|
260
|
+
if (kept.length === rest.length)
|
|
261
|
+
return content;
|
|
262
|
+
return textFirst(first.text, kept);
|
|
263
|
+
}
|
|
264
|
+
/** The ids to remove for a message's rows: each row once, and a body stored
|
|
265
|
+
* in shards once, since removing one shard takes out its whole body. */
|
|
266
|
+
function oncePerBody(rows) {
|
|
267
|
+
const bodies = new Set();
|
|
268
|
+
const ids = [];
|
|
269
|
+
for (const row of rows) {
|
|
270
|
+
if (row.bodyGroupId) {
|
|
271
|
+
if (bodies.has(row.bodyGroupId))
|
|
272
|
+
continue;
|
|
273
|
+
bodies.add(row.bodyGroupId);
|
|
274
|
+
}
|
|
275
|
+
ids.push(row.id);
|
|
276
|
+
}
|
|
277
|
+
return ids;
|
|
278
|
+
}
|
|
175
279
|
/**
|
|
176
280
|
* Discord module for the agent framework.
|
|
177
281
|
*/
|
|
@@ -207,6 +311,21 @@ export class DiscordModule {
|
|
|
207
311
|
// live path saw is newer than what the sync fetched, so the sync leaves
|
|
208
312
|
// those messages alone.
|
|
209
313
|
liveDuringSync = new Set();
|
|
314
|
+
// The Discord messages whose live create is on its way to the store: from
|
|
315
|
+
// the moment the live path takes one up, through its conversion and the
|
|
316
|
+
// event queue, until a query shows the row onProcess answered with, stored
|
|
317
|
+
// or held. The framework holds a module's writes while the agent's turn is
|
|
318
|
+
// alive or the host is quiesced, and queryMessages shows a held write's
|
|
319
|
+
// row, marked held, so the next Discord event finds it either way and ends
|
|
320
|
+
// its entry. A history sync stores none of these messages, so no message is
|
|
321
|
+
// stored twice. A delete before the create is handled drops it: nothing is
|
|
322
|
+
// stored and nobody is woken. Any message's entry ends when its delete is
|
|
323
|
+
// handled, since a sync running then skips the message (noteLive) and a
|
|
324
|
+
// later one doesn't fetch it. An edit while it converts is folded into it.
|
|
325
|
+
// A create whose answer the framework fails to write or hold shows no row,
|
|
326
|
+
// and keeps its entry, and the sync off its message, until its delete is
|
|
327
|
+
// handled or the module stops.
|
|
328
|
+
creating = new Map();
|
|
210
329
|
// Typing keepalive: agentName -> { interval, deadline }. Started on
|
|
211
330
|
// inference:started, stopped on the turn's terminal trace event. Discord's
|
|
212
331
|
// typing indicator auto-expires after ~10s, so we re-trigger on an interval
|
|
@@ -249,7 +368,7 @@ export class DiscordModule {
|
|
|
249
368
|
}
|
|
250
369
|
// Set up Discord event handlers
|
|
251
370
|
this.client.onMessage((message) => this.handleDiscordMessage(message));
|
|
252
|
-
this.client.onMessageEdit((id, content) => this.handleDiscordMessageEdit(id, content));
|
|
371
|
+
this.client.onMessageEdit((id, content, attachments) => this.handleDiscordMessageEdit(id, content, attachments));
|
|
253
372
|
this.client.onMessageDelete((id) => this.handleDiscordMessageDelete(id));
|
|
254
373
|
// Set up ready handler for history sync on (re)connect
|
|
255
374
|
if (this.client.onReady) {
|
|
@@ -283,8 +402,13 @@ export class DiscordModule {
|
|
|
283
402
|
clearInterval(interval);
|
|
284
403
|
}
|
|
285
404
|
this.typingLoops.clear();
|
|
286
|
-
|
|
405
|
+
// From here the module writes nothing, though the framework may dispatch
|
|
406
|
+
// to it until stop() resolves: the live path takes nothing up, a create
|
|
407
|
+
// still converting finds the context gone, and onProcess answers nothing.
|
|
408
|
+
// The record of creates on their way goes with the context.
|
|
287
409
|
this.ctx = null;
|
|
410
|
+
this.creating.clear();
|
|
411
|
+
await this.client.disconnect();
|
|
288
412
|
}
|
|
289
413
|
/**
|
|
290
414
|
* Start (or refresh) the typing keepalive for an agent's reply channel.
|
|
@@ -468,7 +592,7 @@ export class DiscordModule {
|
|
|
468
592
|
},
|
|
469
593
|
{
|
|
470
594
|
name: 'list_channels',
|
|
471
|
-
description: 'List
|
|
595
|
+
description: 'List the channels in a Discord server, with their ids. Threads aren\'t listed.',
|
|
472
596
|
inputSchema: {
|
|
473
597
|
type: 'object',
|
|
474
598
|
properties: {
|
|
@@ -512,7 +636,8 @@ export class DiscordModule {
|
|
|
512
636
|
case 'set_reply_context':
|
|
513
637
|
return await this.handleSetReplyContext(call.input);
|
|
514
638
|
case 'activate':
|
|
515
|
-
|
|
639
|
+
// A call with no input at all activates with the defaults.
|
|
640
|
+
return await this.handleActivate((call.input ?? {}));
|
|
516
641
|
case 'deactivate':
|
|
517
642
|
return await this.handleDeactivate();
|
|
518
643
|
case 'list_guilds':
|
|
@@ -522,21 +647,38 @@ export class DiscordModule {
|
|
|
522
647
|
case 'fetch_history':
|
|
523
648
|
return await this.handleFetchHistory(call.input);
|
|
524
649
|
default:
|
|
525
|
-
return { success: false, error:
|
|
650
|
+
return { success: false, error: unknownToolMessage(this.name, call.name, this.getTools()), isError: true };
|
|
526
651
|
}
|
|
527
652
|
}
|
|
528
653
|
catch (error) {
|
|
654
|
+
// A refusal says what was refused and what to do instead; anything else
|
|
655
|
+
// is unexpected, and its outcome is worded by the tool.
|
|
529
656
|
return {
|
|
530
657
|
success: false,
|
|
531
|
-
error: error instanceof
|
|
658
|
+
error: error instanceof DiscordRefusal ? error.message : unexpectedFailure(call, error),
|
|
532
659
|
isError: true,
|
|
533
660
|
};
|
|
534
661
|
}
|
|
535
662
|
}
|
|
536
663
|
async onProcess(event, state) {
|
|
664
|
+
// A stopped module answers nothing (stop()).
|
|
665
|
+
if (!this.ctx)
|
|
666
|
+
return {};
|
|
667
|
+
// Each Discord event ends the entries whose row a query shows now, stored
|
|
668
|
+
// or held.
|
|
669
|
+
if (event.type.startsWith('discord:')) {
|
|
670
|
+
for (const [id, live] of this.creating)
|
|
671
|
+
if (live.phase === 'writing' && this.storedRow(id))
|
|
672
|
+
this.creating.delete(id);
|
|
673
|
+
}
|
|
537
674
|
// Handle new Discord message
|
|
538
675
|
if (event.type === 'discord:message') {
|
|
539
676
|
const msg = event;
|
|
677
|
+
const live = this.creating.get(msg.discordMessageId);
|
|
678
|
+
// Deleted while its create waited in the queue: nothing is stored, and
|
|
679
|
+
// nobody is woken by a message its author took back.
|
|
680
|
+
if (live?.deleted)
|
|
681
|
+
return {};
|
|
540
682
|
const currentState = state.getState() ?? {
|
|
541
683
|
connectedGuilds: [],
|
|
542
684
|
conversations: {},
|
|
@@ -579,20 +721,29 @@ export class DiscordModule {
|
|
|
579
721
|
// when an event carries no blocks; text first, as TEXT_FIRST marks.
|
|
580
722
|
const contentBlocks = msg.contentBlocks ?? textFirst(ownText(msg.content));
|
|
581
723
|
this.noteLive(msg.discordMessageId);
|
|
724
|
+
// A message a history sync wrote first, stored or held, keeps that row:
|
|
725
|
+
// the create adds none, and still sets the reply context and asks for
|
|
726
|
+
// inference, since the message arrived live. Either way the entry waits
|
|
727
|
+
// until a Discord event finds the message's row.
|
|
728
|
+
const stored = this.storedRow(msg.discordMessageId);
|
|
729
|
+
if (live)
|
|
730
|
+
live.phase = 'writing';
|
|
582
731
|
// Return declarative response - framework applies all writes atomically
|
|
583
732
|
return {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
733
|
+
...(stored ? {} : {
|
|
734
|
+
addMessages: [
|
|
735
|
+
{
|
|
736
|
+
participant: msg.authorName,
|
|
737
|
+
content: contentBlocks,
|
|
738
|
+
metadata: {
|
|
739
|
+
external: { source: 'discord', id: msg.discordMessageId },
|
|
740
|
+
channelId: msg.channelId,
|
|
741
|
+
timestamp: msg.timestamp,
|
|
742
|
+
[TEXT_FIRST]: true,
|
|
743
|
+
},
|
|
593
744
|
},
|
|
594
|
-
|
|
595
|
-
|
|
745
|
+
],
|
|
746
|
+
}),
|
|
596
747
|
stateUpdate: newState,
|
|
597
748
|
requestInference: msg.shouldTriggerInference ?? true,
|
|
598
749
|
};
|
|
@@ -600,15 +751,18 @@ export class DiscordModule {
|
|
|
600
751
|
// Handle Discord message edit
|
|
601
752
|
if (event.type === 'discord:edit') {
|
|
602
753
|
const edit = event;
|
|
603
|
-
// The
|
|
604
|
-
// text and keeps what the host added beside it
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
754
|
+
// The message's row, not only its id: an edit changes the message's own
|
|
755
|
+
// text and keeps what the host added beside it, but for the blocks of
|
|
756
|
+
// attachments the edit took away, when the client passed the edit's
|
|
757
|
+
// attachments and the row is text-first.
|
|
758
|
+
// An edit that comes while the live path converts the message's create
|
|
759
|
+
// is folded into that create (handleDiscordMessageEdit). Any other edit
|
|
760
|
+
// to a message with no row, stored or held, is dropped: a history sync
|
|
761
|
+
// running now stores the text it fetched, and the next sync's text rule
|
|
762
|
+
// changes it. A row stored in shards can't be edited one shard at a
|
|
763
|
+
// time, so it keeps its text, and the host's log says so. Only an edit
|
|
764
|
+
// that writes marks the message live, so an update that changes nothing
|
|
765
|
+
// leaves a running sync free to rewrite the row.
|
|
612
766
|
const stored = this.storedRow(edit.discordMessageId);
|
|
613
767
|
if (!stored)
|
|
614
768
|
return {};
|
|
@@ -616,8 +770,11 @@ export class DiscordModule {
|
|
|
616
770
|
console.warn(`Discord: An update to message ${edit.discordMessageId} wasn't applied: it's stored in shards, which can't be edited, so if its text changed, the agent still has the earlier text`);
|
|
617
771
|
return {};
|
|
618
772
|
}
|
|
619
|
-
const
|
|
620
|
-
|
|
773
|
+
const texted = contentForText(stored, edit.newContent);
|
|
774
|
+
const content = edit.attachments && stored.metadata?.[TEXT_FIRST] === true
|
|
775
|
+
? withoutGoneAttachments(texted ?? stored.content, edit.attachments)
|
|
776
|
+
: texted;
|
|
777
|
+
if (!content || content === stored.content)
|
|
621
778
|
return {};
|
|
622
779
|
this.noteLive(edit.discordMessageId);
|
|
623
780
|
return { editMessages: [{ messageId: stored.id, content }] };
|
|
@@ -625,17 +782,19 @@ export class DiscordModule {
|
|
|
625
782
|
// Handle Discord message delete
|
|
626
783
|
if (event.type === 'discord:delete') {
|
|
627
784
|
const del = event;
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
785
|
+
// Every row of the message goes, so one stored twice doesn't outlive
|
|
786
|
+
// its delete as a copy. A row stored in shards is removed whole: the
|
|
787
|
+
// framework takes out the body a shard belongs to. The message is the
|
|
788
|
+
// live path's even when no row is found, so a running sync doesn't
|
|
789
|
+
// store what its fetch may still hold, and that ends its entry.
|
|
632
790
|
this.noteLive(del.discordMessageId);
|
|
633
|
-
|
|
634
|
-
|
|
791
|
+
this.creating.delete(del.discordMessageId);
|
|
792
|
+
const rows = this.storedRows(del.discordMessageId);
|
|
793
|
+
if (rows.length === 0) {
|
|
635
794
|
return {};
|
|
636
795
|
}
|
|
637
796
|
return {
|
|
638
|
-
removeMessages:
|
|
797
|
+
removeMessages: oncePerBody(rows),
|
|
639
798
|
};
|
|
640
799
|
}
|
|
641
800
|
return {};
|
|
@@ -679,7 +838,7 @@ export class DiscordModule {
|
|
|
679
838
|
return;
|
|
680
839
|
}
|
|
681
840
|
// Check rate limit
|
|
682
|
-
if (
|
|
841
|
+
if (this.rateLimitWait(replyCtx.channelId) > 0) {
|
|
683
842
|
console.warn(`Discord: Rate limited for channel ${replyCtx.channelId}`);
|
|
684
843
|
return;
|
|
685
844
|
}
|
|
@@ -770,7 +929,8 @@ export class DiscordModule {
|
|
|
770
929
|
}
|
|
771
930
|
/**
|
|
772
931
|
* Sync history for a specific channel - compare with Context Manager and update.
|
|
773
|
-
* Uses queryMessages() to enumerate
|
|
932
|
+
* Uses queryMessages() to enumerate the channel's rows, a held write's among
|
|
933
|
+
* them, for proper edit/delete detection.
|
|
774
934
|
*
|
|
775
935
|
* The live path can store, edit or remove a row whenever the sync awaits.
|
|
776
936
|
* So the sync fetches history and runs the live conversion it needs first,
|
|
@@ -813,7 +973,7 @@ export class DiscordModule {
|
|
|
813
973
|
// edited one shard at a time, so it's left as it is.
|
|
814
974
|
const converted = new Map();
|
|
815
975
|
for (const discordMsg of chronologicalMessages) {
|
|
816
|
-
const stored = before.get(discordMsg.id);
|
|
976
|
+
const stored = before.get(discordMsg.id)?.[0];
|
|
817
977
|
if (stored && (stored.bodyGroupId || !needsRebuild(stored, discordMsg)))
|
|
818
978
|
continue;
|
|
819
979
|
const content = await this.convertMessageToContent(discordMsg);
|
|
@@ -834,13 +994,14 @@ export class DiscordModule {
|
|
|
834
994
|
for (const discordMsg of chronologicalMessages) {
|
|
835
995
|
if (live.has(discordMsg.id))
|
|
836
996
|
continue;
|
|
837
|
-
const stored = storedMessageMap.get(discordMsg.id);
|
|
997
|
+
const stored = storedMessageMap.get(discordMsg.id)?.[0];
|
|
838
998
|
if (!stored) {
|
|
839
999
|
// A message we haven't stored. One that was stored when the sync
|
|
840
1000
|
// began, and isn't now, was removed meanwhile: not the sync's to
|
|
841
|
-
// bring back.
|
|
1001
|
+
// bring back. One the live path is storing (creating) is the live
|
|
1002
|
+
// path's, so it isn't stored twice.
|
|
842
1003
|
const content = converted.get(discordMsg.id);
|
|
843
|
-
if (!content || before.has(discordMsg.id))
|
|
1004
|
+
if (!content || before.has(discordMsg.id) || this.creating.has(discordMsg.id))
|
|
844
1005
|
continue;
|
|
845
1006
|
// For bot's own messages (from previous sessions), use the agent name
|
|
846
1007
|
// This ensures continuity of conversation history
|
|
@@ -876,7 +1037,8 @@ export class DiscordModule {
|
|
|
876
1037
|
const oldestFetchedTimestamp = discordMessages.length > 0
|
|
877
1038
|
? Math.min(...discordMessages.map((msg) => msg.timestamp.getTime()))
|
|
878
1039
|
: undefined;
|
|
879
|
-
for (const [externalId,
|
|
1040
|
+
for (const [externalId, rows] of storedMessageMap) {
|
|
1041
|
+
const stored = rows[0];
|
|
880
1042
|
const sourceTimestamp = typeof stored.metadata?.timestamp === 'number'
|
|
881
1043
|
? stored.metadata.timestamp
|
|
882
1044
|
: undefined;
|
|
@@ -890,8 +1052,10 @@ export class DiscordModule {
|
|
|
890
1052
|
// Message exists in our store but not in Discord - it was deleted
|
|
891
1053
|
// Note: This only catches deletes within the scrollback window.
|
|
892
1054
|
// A message the live path wrote while the sync ran is newer than
|
|
893
|
-
// the fetch, so it stays.
|
|
894
|
-
|
|
1055
|
+
// the fetch, so it stays. Every row of the message goes, and one
|
|
1056
|
+
// shard's id takes out a body whole.
|
|
1057
|
+
for (const id of oncePerBody(rows))
|
|
1058
|
+
ctx.removeMessage(id);
|
|
895
1059
|
deletedMessages++;
|
|
896
1060
|
}
|
|
897
1061
|
}
|
|
@@ -908,22 +1072,38 @@ export class DiscordModule {
|
|
|
908
1072
|
}
|
|
909
1073
|
}
|
|
910
1074
|
/** A channel's stored Discord rows by their Discord message id, whole: an
|
|
911
|
-
* edit replaces a row's own text and keeps what the host added beside it.
|
|
1075
|
+
* edit replaces a row's own text and keeps what the host added beside it.
|
|
1076
|
+
* Each id's rows are in store order. A message stored twice holds two,
|
|
1077
|
+
* of which a sync acts on the first, as the live path does (storedRow),
|
|
1078
|
+
* and a delete takes both. A write the framework holds is a row too
|
|
1079
|
+
* (storedRows). */
|
|
912
1080
|
storedByDiscordId(ctx, channelId) {
|
|
913
1081
|
const rows = new Map();
|
|
914
1082
|
for (const msg of ctx.queryMessages({ source: 'discord', metadata: { channelId } }).messages) {
|
|
915
1083
|
const external = msg.metadata?.external;
|
|
916
|
-
if (external?.id)
|
|
917
|
-
|
|
1084
|
+
if (!external?.id)
|
|
1085
|
+
continue;
|
|
1086
|
+
const held = rows.get(external.id);
|
|
1087
|
+
if (held)
|
|
1088
|
+
held.push(msg);
|
|
1089
|
+
else
|
|
1090
|
+
rows.set(external.id, [msg]);
|
|
918
1091
|
}
|
|
919
1092
|
return rows;
|
|
920
1093
|
}
|
|
921
|
-
/** The stored
|
|
922
|
-
*
|
|
1094
|
+
/** The stored rows of a Discord message, by its id in the row's metadata,
|
|
1095
|
+
* in store order. A write the framework holds comes after them as a held
|
|
1096
|
+
* row, which an edit or removal reaches as it does a stored one. The
|
|
1097
|
+
* module context's findMessageByExternalId holds only rows the module
|
|
923
1098
|
* stored through its context, not those a process response added, so it
|
|
924
1099
|
* misses what the live path stored. */
|
|
1100
|
+
storedRows(discordMessageId) {
|
|
1101
|
+
return this.ctx?.queryMessages({ source: 'discord', externalIds: [discordMessageId] }).messages ?? [];
|
|
1102
|
+
}
|
|
1103
|
+
/** The row an edit or a create acts on: a message's first row, a stored
|
|
1104
|
+
* one before a held one (storedRows). */
|
|
925
1105
|
storedRow(discordMessageId) {
|
|
926
|
-
return this.
|
|
1106
|
+
return this.storedRows(discordMessageId)[0];
|
|
927
1107
|
}
|
|
928
1108
|
/** Marks a Discord message as the live path's for every history sync running now. */
|
|
929
1109
|
noteLive(discordMessageId) {
|
|
@@ -931,7 +1111,8 @@ export class DiscordModule {
|
|
|
931
1111
|
live.add(discordMessageId);
|
|
932
1112
|
}
|
|
933
1113
|
async handleDiscordMessage(message) {
|
|
934
|
-
|
|
1114
|
+
const ctx = this.ctx;
|
|
1115
|
+
if (!ctx)
|
|
935
1116
|
return;
|
|
936
1117
|
// ALWAYS ignore our own messages to prevent feedback loops
|
|
937
1118
|
if (this.state.botUserId && message.authorId === this.state.botUserId) {
|
|
@@ -958,20 +1139,52 @@ export class DiscordModule {
|
|
|
958
1139
|
}
|
|
959
1140
|
// Determine if this message should trigger inference
|
|
960
1141
|
const shouldTrigger = this.shouldTriggerInference(message, isDM);
|
|
1142
|
+
// From here the message is the live path's to store (creating). A second
|
|
1143
|
+
// delivery of a message whose create is already on its way is ignored.
|
|
1144
|
+
if (this.creating.has(message.id))
|
|
1145
|
+
return;
|
|
1146
|
+
const live = { phase: 'converting', deleted: false };
|
|
1147
|
+
this.creating.set(message.id, live);
|
|
1148
|
+
const drop = () => { if (this.creating.get(message.id) === live)
|
|
1149
|
+
this.creating.delete(message.id); };
|
|
961
1150
|
// Convert message content including attachments
|
|
962
|
-
|
|
1151
|
+
let contentBlocks;
|
|
1152
|
+
try {
|
|
1153
|
+
contentBlocks = await this.convertMessageToContent(message);
|
|
1154
|
+
}
|
|
1155
|
+
catch (error) {
|
|
1156
|
+
drop();
|
|
1157
|
+
throw error;
|
|
1158
|
+
}
|
|
1159
|
+
// A module stopped or started again meanwhile writes nothing.
|
|
1160
|
+
if (this.ctx !== ctx) {
|
|
1161
|
+
drop();
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
// A delete that came meanwhile drops the create: nothing is stored,
|
|
1165
|
+
// nobody is woken. Its entry stays until that delete is handled, so no
|
|
1166
|
+
// sync stores the message before then.
|
|
1167
|
+
if (live.deleted)
|
|
1168
|
+
return;
|
|
1169
|
+
// An edit that came meanwhile: its text, and the blocks of the
|
|
1170
|
+
// attachments it left.
|
|
1171
|
+
if (live.text !== undefined)
|
|
1172
|
+
contentBlocks = withOwnText(contentBlocks, live.text);
|
|
1173
|
+
if (live.attachments)
|
|
1174
|
+
contentBlocks = withoutGoneAttachments(contentBlocks, live.attachments);
|
|
1175
|
+
live.phase = 'queued';
|
|
963
1176
|
// Only queue the event - all state writes happen in onProcess()
|
|
964
|
-
|
|
1177
|
+
ctx.pushEvent({
|
|
965
1178
|
type: 'discord:message',
|
|
966
1179
|
discordMessageId: message.id,
|
|
967
1180
|
channelId: message.channelId,
|
|
968
1181
|
guildId: message.guildId,
|
|
969
1182
|
authorId: message.authorId,
|
|
970
1183
|
authorName: message.authorName,
|
|
971
|
-
content: message.content,
|
|
1184
|
+
content: live.text ?? message.content,
|
|
972
1185
|
contentBlocks,
|
|
973
1186
|
timestamp: message.timestamp.getTime(),
|
|
974
|
-
attachments: message.attachments,
|
|
1187
|
+
attachments: live.attachments ?? message.attachments,
|
|
975
1188
|
shouldTriggerInference: shouldTrigger,
|
|
976
1189
|
});
|
|
977
1190
|
}
|
|
@@ -1000,7 +1213,15 @@ export class DiscordModule {
|
|
|
1000
1213
|
}
|
|
1001
1214
|
// Check for text files - fetch and include content
|
|
1002
1215
|
const isTextFile = TEXT_FILE_EXTENSIONS.some(ext => filename.endsWith(ext));
|
|
1003
|
-
if (isTextFile && attachment.size
|
|
1216
|
+
if (isTextFile && attachment.size >= TEXT_FILE_LIMIT_BYTES) {
|
|
1217
|
+
blocks.push({
|
|
1218
|
+
type: 'text',
|
|
1219
|
+
text: `[Attachment: ${attachment.filename} (${attachment.size.toLocaleString('en-US')} bytes): this host reads text files under ${TEXT_FILE_LIMIT_BYTES.toLocaleString('en-US')} bytes into your context, so its text isn't here. If you need it, ask the sender to paste the part that matters.]`,
|
|
1220
|
+
});
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
// A text file of size 0 gets the note below, as before.
|
|
1224
|
+
if (isTextFile && attachment.size) {
|
|
1004
1225
|
try {
|
|
1005
1226
|
const response = await fetch(attachment.url);
|
|
1006
1227
|
if (response.ok) {
|
|
@@ -1026,7 +1247,7 @@ export class DiscordModule {
|
|
|
1026
1247
|
// For unsupported attachments, add a note
|
|
1027
1248
|
blocks.push({
|
|
1028
1249
|
type: 'text',
|
|
1029
|
-
text: `[Attachment: ${attachment.filename} (${contentType || 'unknown type'})]`,
|
|
1250
|
+
text: `[Attachment: ${attachment.filename} (${contentType || 'unknown type'}): this host didn't read its contents in, so they aren't in your context. If you need them, ask the sender for them in a form you can read, such as pasted text.]`,
|
|
1030
1251
|
});
|
|
1031
1252
|
}
|
|
1032
1253
|
return textFirst(ownText(message.content), blocks);
|
|
@@ -1063,19 +1284,35 @@ export class DiscordModule {
|
|
|
1063
1284
|
return isMentioned || isReplyToBot;
|
|
1064
1285
|
}
|
|
1065
1286
|
}
|
|
1066
|
-
handleDiscordMessageEdit(messageId, newContent) {
|
|
1287
|
+
handleDiscordMessageEdit(messageId, newContent, attachments) {
|
|
1067
1288
|
if (!this.ctx)
|
|
1068
1289
|
return;
|
|
1290
|
+
// An edit while the message's create converts is folded into that
|
|
1291
|
+
// create, which would otherwise store the text it was sent with; one that
|
|
1292
|
+
// comes after follows the create through the queue. Its event still goes,
|
|
1293
|
+
// for a row a history sync stored first.
|
|
1294
|
+
const live = this.creating.get(messageId);
|
|
1295
|
+
if (live) {
|
|
1296
|
+
live.text = newContent;
|
|
1297
|
+
if (attachments)
|
|
1298
|
+
live.attachments = attachments;
|
|
1299
|
+
}
|
|
1069
1300
|
// Only queue the event - state writes happen in onProcess()
|
|
1070
1301
|
this.ctx.pushEvent({
|
|
1071
1302
|
type: 'discord:edit',
|
|
1072
1303
|
discordMessageId: messageId,
|
|
1073
1304
|
newContent,
|
|
1305
|
+
...(attachments ? { attachments } : {}),
|
|
1074
1306
|
});
|
|
1075
1307
|
}
|
|
1076
1308
|
handleDiscordMessageDelete(messageId) {
|
|
1077
1309
|
if (!this.ctx)
|
|
1078
1310
|
return;
|
|
1311
|
+
// A delete before the message's create is handled drops that create.
|
|
1312
|
+
// Its event still goes, for a row a history sync stored first.
|
|
1313
|
+
const live = this.creating.get(messageId);
|
|
1314
|
+
if (live)
|
|
1315
|
+
live.deleted = true;
|
|
1079
1316
|
// Only queue the event - state writes happen in onProcess()
|
|
1080
1317
|
this.ctx.pushEvent({
|
|
1081
1318
|
type: 'discord:delete',
|
|
@@ -1104,8 +1341,13 @@ export class DiscordModule {
|
|
|
1104
1341
|
}
|
|
1105
1342
|
}
|
|
1106
1343
|
async handleSend(input, call) {
|
|
1107
|
-
|
|
1108
|
-
|
|
1344
|
+
const wait = this.rateLimitWait(input.channelId);
|
|
1345
|
+
if (wait > 0) {
|
|
1346
|
+
return {
|
|
1347
|
+
success: false,
|
|
1348
|
+
error: `Nothing was sent: channel ${input.channelId} has reached ${this.rateLimitRule()}. Send it again in ${seconds(wait)}; other channels have their own limit.`,
|
|
1349
|
+
isError: true,
|
|
1350
|
+
};
|
|
1109
1351
|
}
|
|
1110
1352
|
const result = await this.client.sendMessage(input.channelId, input.content, {
|
|
1111
1353
|
replyTo: input.replyTo,
|
|
@@ -1117,28 +1359,61 @@ export class DiscordModule {
|
|
|
1117
1359
|
if (result.threadFailure !== undefined) {
|
|
1118
1360
|
return { success: false, error: result.threadFailure, isError: true };
|
|
1119
1361
|
}
|
|
1120
|
-
return { success: true, data: { messageId: result.messageId } };
|
|
1362
|
+
return { success: true, data: { messageId: result.messageId, ...(result.note !== undefined ? { note: result.note } : {}) } };
|
|
1121
1363
|
}
|
|
1122
1364
|
async handleDM(input) {
|
|
1123
1365
|
const result = await this.client.sendDM(input.userId, input.content);
|
|
1124
|
-
|
|
1366
|
+
// The direct message channel's id is what fetch_history reads it by, should a later dm need checking.
|
|
1367
|
+
return { success: true, data: { messageId: result.messageId, ...(result.channelId !== undefined ? { channelId: result.channelId } : {}) } };
|
|
1125
1368
|
}
|
|
1126
1369
|
async handleReply(input, call) {
|
|
1127
1370
|
const replyCtx = this.replyContexts.get('default');
|
|
1128
1371
|
if (!replyCtx) {
|
|
1129
|
-
return {
|
|
1372
|
+
return {
|
|
1373
|
+
success: false,
|
|
1374
|
+
error: 'Nothing was sent: there\'s no conversation to reply in. Use discord--send with a channelId to post somewhere, or discord--set_reply_context with a channelId to choose where replies go, then reply again.',
|
|
1375
|
+
isError: true,
|
|
1376
|
+
};
|
|
1130
1377
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1378
|
+
// The reply goes to the context's thread when it names one, an empty id
|
|
1379
|
+
// as well, as it always has; the words name an empty id as what it is.
|
|
1380
|
+
const thread = replyCtx.threadId;
|
|
1381
|
+
const wait = this.rateLimitWait(replyCtx.channelId);
|
|
1382
|
+
if (wait > 0) {
|
|
1383
|
+
return {
|
|
1384
|
+
success: false,
|
|
1385
|
+
error: `Nothing was sent: this conversation's channel (${replyCtx.channelId}) has reached ${this.rateLimitRule()}`
|
|
1386
|
+
+ `${thread ? `, which counts its replies in thread ${thread} too` : ''}. Reply again in ${seconds(wait)}.`,
|
|
1387
|
+
isError: true,
|
|
1388
|
+
};
|
|
1133
1389
|
}
|
|
1134
1390
|
let content = input.content;
|
|
1135
1391
|
if (input.mention && replyCtx.userId) {
|
|
1136
1392
|
content = `<@${replyCtx.userId}> ${content}`;
|
|
1137
1393
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}
|
|
1394
|
+
// reply's call names no channel or message: its reply context does, as it
|
|
1395
|
+
// stands now, before the send, which an incoming message can change.
|
|
1396
|
+
const targetChannel = thread ?? replyCtx.channelId;
|
|
1397
|
+
const pointsAt = thread === undefined ? `channel ${targetChannel}` : thread === '' ? 'an empty thread id' : `thread ${targetChannel}`;
|
|
1398
|
+
const check = thread === ''
|
|
1399
|
+
? `, and its reply context names an empty thread id: discord--set_reply_context with a threadId, or without one to reply in channel ${replyCtx.channelId}, puts that right`
|
|
1400
|
+
: `: discord--fetch_history with channelId ${targetChannel}, its reply context's ${thread !== undefined ? 'thread' : 'channel'}, shows it, so check before you reply again`;
|
|
1401
|
+
const replyTo = replyCtx.replyToMessageId;
|
|
1402
|
+
let result;
|
|
1403
|
+
try {
|
|
1404
|
+
result = await this.client.sendMessage(targetChannel, content, { replyTo });
|
|
1405
|
+
}
|
|
1406
|
+
catch (error) {
|
|
1407
|
+
if (error instanceof DiscordRefusal) {
|
|
1408
|
+
throw new DiscordRefusal(`${error.message} A reply goes where its reply context points: ${pointsAt}`
|
|
1409
|
+
+ `${replyTo ? `, answering message ${replyTo}` : ''}. discord--set_reply_context changes that.`, { cause: error });
|
|
1410
|
+
}
|
|
1411
|
+
return {
|
|
1412
|
+
success: false,
|
|
1413
|
+
error: `discord--reply stopped on an unexpected error: ${errorText(error)}. Whether the reply was sent is unknown${check}.`,
|
|
1414
|
+
isError: true,
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1142
1417
|
// Track for thought-editing
|
|
1143
1418
|
this.notePost(call.callerAgentName, call.callerTurnId, targetChannel, result.messageId);
|
|
1144
1419
|
// Clear reply-to after sending
|
|
@@ -1188,14 +1463,14 @@ export class DiscordModule {
|
|
|
1188
1463
|
}
|
|
1189
1464
|
async handleActivate(input) {
|
|
1190
1465
|
if (!this.ctx) {
|
|
1191
|
-
return { success: false, error:
|
|
1466
|
+
return { success: false, error: NOT_RUNNING, isError: true };
|
|
1192
1467
|
}
|
|
1193
1468
|
this.ctx.registerSpeechHandler('*', { additive: input.additive });
|
|
1194
1469
|
return { success: true, data: { message: 'Discord is now handling speech' } };
|
|
1195
1470
|
}
|
|
1196
1471
|
async handleDeactivate() {
|
|
1197
1472
|
if (!this.ctx) {
|
|
1198
|
-
return { success: false, error:
|
|
1473
|
+
return { success: false, error: NOT_RUNNING, isError: true };
|
|
1199
1474
|
}
|
|
1200
1475
|
this.ctx.unregisterSpeechHandler();
|
|
1201
1476
|
return { success: true, data: { message: 'Discord is no longer handling speech' } };
|
|
@@ -1271,8 +1546,23 @@ export class DiscordModule {
|
|
|
1271
1546
|
.map((block) => block.text)
|
|
1272
1547
|
.join('\n');
|
|
1273
1548
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1549
|
+
rateLimitPerMinute() {
|
|
1550
|
+
return this.config.rateLimitPerMinute ?? 30;
|
|
1551
|
+
}
|
|
1552
|
+
/** The limit, as the rate-limit refusals word it. */
|
|
1553
|
+
rateLimitRule() {
|
|
1554
|
+
const limit = this.rateLimitPerMinute();
|
|
1555
|
+
return `this host's limit of ${limit} send${limit === 1 ? '' : 's'} a minute from the bot`;
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Counts a send to `channelId` against its limit and returns 0, or, when
|
|
1559
|
+
* the channel has had its sends for this window, returns the whole seconds
|
|
1560
|
+
* until the window ends, at least 1. A window is the minute from the first
|
|
1561
|
+
* send this lets through, and each send it lets through counts, whether or
|
|
1562
|
+
* not Discord then takes it.
|
|
1563
|
+
*/
|
|
1564
|
+
rateLimitWait(channelId) {
|
|
1565
|
+
const limit = this.rateLimitPerMinute();
|
|
1276
1566
|
const now = Date.now();
|
|
1277
1567
|
const windowMs = 60 * 1000;
|
|
1278
1568
|
let info = this.state.rateLimits[channelId];
|
|
@@ -1282,10 +1572,10 @@ export class DiscordModule {
|
|
|
1282
1572
|
this.state.rateLimits[channelId] = info;
|
|
1283
1573
|
}
|
|
1284
1574
|
if (info.count >= limit) {
|
|
1285
|
-
return
|
|
1575
|
+
return Math.max(1, Math.ceil((info.windowStart + windowMs - now) / 1000));
|
|
1286
1576
|
}
|
|
1287
1577
|
info.count++;
|
|
1288
|
-
return
|
|
1578
|
+
return 0;
|
|
1289
1579
|
}
|
|
1290
1580
|
cleanupOldConversations() {
|
|
1291
1581
|
const now = Date.now();
|