@hybridaione/hybridclaw 0.2.2 → 0.2.6
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/.github/workflows/ci.yml +70 -0
- package/.husky/pre-commit +1 -0
- package/CHANGELOG.md +85 -0
- package/CONTRIBUTING.md +33 -0
- package/README.md +41 -16
- package/SECURITY.md +17 -0
- package/biome.json +35 -0
- package/config.example.json +71 -8
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/approval-policy.ts +1303 -0
- package/container/src/browser-tools.ts +431 -136
- package/container/src/extensions.ts +36 -12
- package/container/src/hybridai-client.ts +34 -13
- package/container/src/index.ts +451 -109
- package/container/src/ipc.ts +5 -3
- package/container/src/token-usage.ts +20 -10
- package/container/src/tools.ts +599 -225
- package/container/src/types.ts +32 -2
- package/container/src/web-fetch.ts +89 -32
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -2
- package/dist/agent.js.map +1 -1
- package/dist/audit-cli.d.ts.map +1 -1
- package/dist/audit-cli.js +4 -2
- package/dist/audit-cli.js.map +1 -1
- package/dist/audit-events.d.ts.map +1 -1
- package/dist/audit-events.js +53 -3
- package/dist/audit-events.js.map +1 -1
- package/dist/audit-trail.d.ts.map +1 -1
- package/dist/audit-trail.js +17 -8
- package/dist/audit-trail.js.map +1 -1
- package/dist/channels/discord/attachments.d.ts.map +1 -1
- package/dist/channels/discord/attachments.js +14 -7
- package/dist/channels/discord/attachments.js.map +1 -1
- package/dist/channels/discord/debounce.d.ts +9 -0
- package/dist/channels/discord/debounce.d.ts.map +1 -0
- package/dist/channels/discord/debounce.js +20 -0
- package/dist/channels/discord/debounce.js.map +1 -0
- package/dist/channels/discord/delivery.d.ts +4 -1
- package/dist/channels/discord/delivery.d.ts.map +1 -1
- package/dist/channels/discord/delivery.js +19 -3
- package/dist/channels/discord/delivery.js.map +1 -1
- package/dist/channels/discord/human-delay.d.ts +16 -0
- package/dist/channels/discord/human-delay.d.ts.map +1 -0
- package/dist/channels/discord/human-delay.js +29 -0
- package/dist/channels/discord/human-delay.js.map +1 -0
- package/dist/channels/discord/inbound.d.ts +4 -0
- package/dist/channels/discord/inbound.d.ts.map +1 -1
- package/dist/channels/discord/inbound.js +45 -4
- package/dist/channels/discord/inbound.js.map +1 -1
- package/dist/channels/discord/mentions.d.ts.map +1 -1
- package/dist/channels/discord/mentions.js +16 -4
- package/dist/channels/discord/mentions.js.map +1 -1
- package/dist/channels/discord/presence.d.ts +33 -0
- package/dist/channels/discord/presence.d.ts.map +1 -0
- package/dist/channels/discord/presence.js +111 -0
- package/dist/channels/discord/presence.js.map +1 -0
- package/dist/channels/discord/rate-limiter.d.ts +14 -0
- package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
- package/dist/channels/discord/rate-limiter.js +49 -0
- package/dist/channels/discord/rate-limiter.js.map +1 -0
- package/dist/channels/discord/reactions.d.ts +38 -0
- package/dist/channels/discord/reactions.d.ts.map +1 -0
- package/dist/channels/discord/reactions.js +151 -0
- package/dist/channels/discord/reactions.js.map +1 -0
- package/dist/channels/discord/runtime.d.ts +6 -3
- package/dist/channels/discord/runtime.d.ts.map +1 -1
- package/dist/channels/discord/runtime.js +621 -125
- package/dist/channels/discord/runtime.js.map +1 -1
- package/dist/channels/discord/stream.d.ts +4 -1
- package/dist/channels/discord/stream.d.ts.map +1 -1
- package/dist/channels/discord/stream.js +16 -8
- package/dist/channels/discord/stream.js.map +1 -1
- package/dist/channels/discord/tool-actions.d.ts.map +1 -1
- package/dist/channels/discord/tool-actions.js +24 -12
- package/dist/channels/discord/tool-actions.js.map +1 -1
- package/dist/channels/discord/typing.d.ts +15 -0
- package/dist/channels/discord/typing.d.ts.map +1 -0
- package/dist/channels/discord/typing.js +106 -0
- package/dist/channels/discord/typing.js.map +1 -0
- package/dist/chunk.d.ts.map +1 -1
- package/dist/chunk.js +4 -2
- package/dist/chunk.js.map +1 -1
- package/dist/cli.js +47 -22
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +103 -18
- package/dist/config.js.map +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +58 -26
- package/dist/container-runner.js.map +1 -1
- package/dist/container-setup.d.ts.map +1 -1
- package/dist/container-setup.js +10 -9
- package/dist/container-setup.js.map +1 -1
- package/dist/conversation.d.ts +2 -2
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +1 -1
- package/dist/conversation.js.map +1 -1
- package/dist/db.d.ts +118 -2
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +1568 -50
- package/dist/db.js.map +1 -1
- package/dist/delegation-manager.d.ts.map +1 -1
- package/dist/delegation-manager.js +3 -2
- package/dist/delegation-manager.js.map +1 -1
- package/dist/gateway-client.d.ts +2 -2
- package/dist/gateway-client.d.ts.map +1 -1
- package/dist/gateway-client.js +10 -4
- package/dist/gateway-client.js.map +1 -1
- package/dist/gateway-service.d.ts +3 -3
- package/dist/gateway-service.d.ts.map +1 -1
- package/dist/gateway-service.js +563 -73
- package/dist/gateway-service.js.map +1 -1
- package/dist/gateway-types.d.ts +24 -0
- package/dist/gateway-types.d.ts.map +1 -1
- package/dist/gateway-types.js.map +1 -1
- package/dist/gateway.js +179 -24
- package/dist/gateway.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +20 -10
- package/dist/health.js.map +1 -1
- package/dist/heartbeat.d.ts +4 -0
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +48 -20
- package/dist/heartbeat.js.map +1 -1
- package/dist/hybridai-bots.d.ts.map +1 -1
- package/dist/hybridai-bots.js +4 -2
- package/dist/hybridai-bots.js.map +1 -1
- package/dist/instruction-approval-audit.d.ts.map +1 -1
- package/dist/instruction-approval-audit.js.map +1 -1
- package/dist/instruction-integrity.d.ts.map +1 -1
- package/dist/instruction-integrity.js +8 -2
- package/dist/instruction-integrity.js.map +1 -1
- package/dist/ipc.d.ts.map +1 -1
- package/dist/ipc.js +6 -1
- package/dist/ipc.js.map +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/memory-consolidation.d.ts +17 -0
- package/dist/memory-consolidation.d.ts.map +1 -0
- package/dist/memory-consolidation.js +25 -0
- package/dist/memory-consolidation.js.map +1 -0
- package/dist/memory-service.d.ts +200 -0
- package/dist/memory-service.d.ts.map +1 -0
- package/dist/memory-service.js +294 -0
- package/dist/memory-service.js.map +1 -0
- package/dist/mount-security.d.ts.map +1 -1
- package/dist/mount-security.js +31 -7
- package/dist/mount-security.js.map +1 -1
- package/dist/observability-ingest.d.ts.map +1 -1
- package/dist/observability-ingest.js +32 -11
- package/dist/observability-ingest.js.map +1 -1
- package/dist/onboarding.d.ts.map +1 -1
- package/dist/onboarding.js +32 -9
- package/dist/onboarding.js.map +1 -1
- package/dist/proactive-policy.d.ts.map +1 -1
- package/dist/proactive-policy.js +2 -1
- package/dist/proactive-policy.js.map +1 -1
- package/dist/prompt-hooks.d.ts.map +1 -1
- package/dist/prompt-hooks.js +9 -7
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +98 -1
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +477 -23
- package/dist/runtime-config.js.map +1 -1
- package/dist/scheduled-task-runner.d.ts +1 -0
- package/dist/scheduled-task-runner.d.ts.map +1 -1
- package/dist/scheduled-task-runner.js +29 -10
- package/dist/scheduled-task-runner.js.map +1 -1
- package/dist/scheduler.d.ts +43 -4
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +530 -56
- package/dist/scheduler.js.map +1 -1
- package/dist/session-export.d.ts +26 -0
- package/dist/session-export.d.ts.map +1 -0
- package/dist/session-export.js +149 -0
- package/dist/session-export.js.map +1 -0
- package/dist/session-maintenance.d.ts.map +1 -1
- package/dist/session-maintenance.js +75 -13
- package/dist/session-maintenance.js.map +1 -1
- package/dist/session-transcripts.d.ts.map +1 -1
- package/dist/session-transcripts.js.map +1 -1
- package/dist/side-effects.d.ts.map +1 -1
- package/dist/side-effects.js +14 -2
- package/dist/side-effects.js.map +1 -1
- package/dist/skills-guard.d.ts.map +1 -1
- package/dist/skills-guard.js +893 -130
- package/dist/skills-guard.js.map +1 -1
- package/dist/skills.d.ts +5 -0
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +29 -15
- package/dist/skills.js.map +1 -1
- package/dist/token-efficiency.d.ts.map +1 -1
- package/dist/token-efficiency.js.map +1 -1
- package/dist/tui.js +92 -11
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +146 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +24 -1
- package/dist/types.js.map +1 -1
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +42 -14
- package/dist/update.js.map +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +49 -9
- package/dist/workspace.js.map +1 -1
- package/docs/chat.html +9 -3
- package/docs/index.html +37 -13
- package/package.json +8 -2
- package/src/agent.ts +16 -3
- package/src/audit-cli.ts +44 -16
- package/src/audit-events.ts +69 -5
- package/src/audit-trail.ts +41 -15
- package/src/channels/discord/attachments.ts +81 -27
- package/src/channels/discord/debounce.ts +25 -0
- package/src/channels/discord/delivery.ts +57 -13
- package/src/channels/discord/human-delay.ts +48 -0
- package/src/channels/discord/inbound.ts +66 -7
- package/src/channels/discord/mentions.ts +42 -18
- package/src/channels/discord/presence.ts +148 -0
- package/src/channels/discord/rate-limiter.ts +58 -0
- package/src/channels/discord/reactions.ts +211 -0
- package/src/channels/discord/runtime.ts +1048 -182
- package/src/channels/discord/stream.ts +73 -27
- package/src/channels/discord/tool-actions.ts +78 -37
- package/src/channels/discord/typing.ts +140 -0
- package/src/chunk.ts +12 -4
- package/src/cli.ts +141 -56
- package/src/config.ts +192 -34
- package/src/container-runner.ts +132 -42
- package/src/container-setup.ts +57 -22
- package/src/conversation.ts +9 -7
- package/src/db.ts +2217 -84
- package/src/delegation-manager.ts +6 -2
- package/src/gateway-client.ts +41 -17
- package/src/gateway-service.ts +1019 -201
- package/src/gateway-types.ts +33 -0
- package/src/gateway.ts +321 -48
- package/src/health.ts +66 -26
- package/src/heartbeat.ts +84 -22
- package/src/hybridai-bots.ts +14 -5
- package/src/instruction-approval-audit.ts +4 -1
- package/src/instruction-integrity.ts +30 -9
- package/src/ipc.ts +23 -5
- package/src/logger.ts +4 -1
- package/src/memory-consolidation.ts +41 -0
- package/src/memory-service.ts +606 -0
- package/src/mount-security.ts +58 -13
- package/src/observability-ingest.ts +134 -35
- package/src/onboarding.ts +126 -35
- package/src/proactive-policy.ts +3 -1
- package/src/prompt-hooks.ts +40 -17
- package/src/runtime-config.ts +1114 -99
- package/src/scheduled-task-runner.ts +63 -11
- package/src/scheduler.ts +683 -60
- package/src/session-export.ts +196 -0
- package/src/session-maintenance.ts +125 -22
- package/src/session-transcripts.ts +12 -3
- package/src/side-effects.ts +28 -5
- package/src/skills-guard.ts +1067 -219
- package/src/skills.ts +163 -65
- package/src/token-efficiency.ts +31 -9
- package/src/tui.ts +166 -25
- package/src/types.ts +195 -2
- package/src/update.ts +79 -23
- package/src/workspace.ts +63 -11
- package/tests/approval-policy.test.ts +224 -0
- package/tests/discord.basic.test.ts +82 -2
- package/tests/discord.human-presence.test.ts +85 -0
- package/tests/gateway-service.media-routing.test.ts +8 -2
- package/tests/memory-service.test.ts +1114 -0
- package/tests/token-efficiency.basic.test.ts +8 -2
- package/vitest.e2e.config.ts +3 -1
- package/vitest.integration.config.ts +3 -1
- package/vitest.live.config.ts +3 -1
- package/vitest.unit.config.ts +9 -0
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { DISCORD_COMMAND_USER_ID, DISCORD_COMMANDS_ONLY, DISCORD_GUILD_MEMBERS_INTENT,
|
|
1
|
+
import { ApplicationCommandOptionType, Client, GatewayIntentBits, Partials, } from 'discord.js';
|
|
2
|
+
import { DISCORD_ACK_REACTION, DISCORD_ACK_REACTION_SCOPE, DISCORD_COMMAND_USER_ID, DISCORD_COMMANDS_ONLY, DISCORD_DEBOUNCE_MS, DISCORD_FREE_RESPONSE_CHANNELS, DISCORD_GROUP_POLICY, DISCORD_GUILD_MEMBERS_INTENT, DISCORD_GUILDS, DISCORD_HUMAN_DELAY, DISCORD_LIFECYCLE_REACTIONS, DISCORD_MAX_CONCURRENT_PER_CHANNEL, DISCORD_PREFIX, DISCORD_PRESENCE_INTENT, DISCORD_RATE_LIMIT_EXEMPT_ROLES, DISCORD_RATE_LIMIT_PER_USER, DISCORD_REMOVE_ACK_AFTER_REPLY, DISCORD_RESPOND_TO_ALL_MESSAGES, DISCORD_SELF_PRESENCE, DISCORD_SUPPRESS_PATTERNS, DISCORD_TOKEN, DISCORD_TYPING_MODE, } from '../../config.js';
|
|
3
|
+
import { logger } from '../../logger.js';
|
|
3
4
|
import { buildAttachmentContext } from './attachments.js';
|
|
5
|
+
import { DEFAULT_DEBOUNCE_MAX_BUFFER, resolveInboundDebounceMs, shouldDebounceInbound, } from './debounce.js';
|
|
6
|
+
import { formatError, prepareChunkedPayloads, sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery, sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery, sendChunkedReply as sendChunkedReplyFromDelivery, } from './delivery.js';
|
|
4
7
|
import { buildSessionIdFromContext as buildSessionIdFromContextInbound, cleanIncomingContent as cleanIncomingContentInbound, hasPrefixInvocation as hasPrefixInvocationInbound, isTrigger as isTriggerInbound, parseCommand as parseCommandInbound, } from './inbound.js';
|
|
5
8
|
import { addMentionAlias, extractMentionAliasHints, normalizeMentionAlias, } from './mentions.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
9
|
+
import { DiscordAutoPresenceController, } from './presence.js';
|
|
10
|
+
import { SlidingWindowRateLimiter } from './rate-limiter.js';
|
|
11
|
+
import { addAckReaction, LifecycleReactionController, } from './reactions.js';
|
|
8
12
|
import { DiscordStreamManager } from './stream.js';
|
|
9
|
-
import {
|
|
13
|
+
import { createDiscordToolActionRunner, } from './tool-actions.js';
|
|
14
|
+
import { createTypingController } from './typing.js';
|
|
10
15
|
let client;
|
|
11
16
|
let messageHandler;
|
|
12
17
|
let commandHandler;
|
|
13
18
|
let activeConversationRuns = 0;
|
|
14
19
|
let botMentionRegex = null;
|
|
15
|
-
const MESSAGE_DEBOUNCE_MS = 2_500;
|
|
16
20
|
const DISCORD_RETRY_MAX_ATTEMPTS = 3;
|
|
17
21
|
const DISCORD_RETRY_BASE_DELAY_MS = 500;
|
|
18
22
|
const GUILD_INBOUND_HISTORY_LIMIT = 20;
|
|
@@ -22,7 +26,32 @@ const PARTICIPANT_MEMORY_MAX_CHANNELS = 200;
|
|
|
22
26
|
const PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL = 200;
|
|
23
27
|
const PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER = 8;
|
|
24
28
|
const MAX_PRESENCE_CACHE_USERS = 5_000;
|
|
29
|
+
const RATE_LIMIT_NOTIFY_COOLDOWN_MS = 12_000;
|
|
30
|
+
const CONCURRENCY_RETRY_DELAY_MS = 250;
|
|
31
|
+
const PRESENCE_WINDOW_MS = 5 * 60_000;
|
|
32
|
+
const PRESENCE_DEGRADED_DURATION_MS = 45_000;
|
|
33
|
+
const PRESENCE_EXHAUSTED_ERROR_RE = /(api down|unavailable|rate limit|too many active containers|quota|token limit|timeout)/i;
|
|
34
|
+
const FRIENDLY_RATE_LIMIT_MESSAGE = "You're sending messages too fast — give me a moment to catch up!";
|
|
35
|
+
const READ_WITHOUT_REPLY_RE = /^(thanks|thank you|thx|ty|got it|ok|okay|cool|perfect|awesome|sounds good|roger)[!. ]*$/i;
|
|
36
|
+
const READ_WITHOUT_REPLY_PROBABILITY = 0.6;
|
|
37
|
+
const STARTUP_STAGGER_WINDOW_MS = 120_000;
|
|
38
|
+
const STARTUP_STAGGER_MIN_DELAY_MS = 500;
|
|
39
|
+
const STARTUP_STAGGER_MAX_DELAY_MS = 3_500;
|
|
40
|
+
const SELECTIVE_SILENCE_BASE_PROBABILITY = 0.25;
|
|
41
|
+
const SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY = 0.5;
|
|
42
|
+
const SELECTIVE_SILENCE_RECENT_WINDOW_MS = 60_000;
|
|
43
|
+
const NIGHT_HOURS_START = 22;
|
|
44
|
+
const NIGHT_HOURS_END = 7;
|
|
45
|
+
const CONVERSATION_COOLDOWN_RESET_MS = 20 * 60_000;
|
|
46
|
+
const CONVERSATION_COOLDOWN_THRESHOLD = 5;
|
|
47
|
+
const CONVERSATION_COOLDOWN_MAX_FACTOR = 2.5;
|
|
25
48
|
const discordPresenceCache = new Map();
|
|
49
|
+
const userRateLimiter = new SlidingWindowRateLimiter(60_000);
|
|
50
|
+
const recentConversationMetrics = [];
|
|
51
|
+
let consecutiveConversationFailures = 0;
|
|
52
|
+
let presenceController = null;
|
|
53
|
+
let startupConnectedAtMs = 0;
|
|
54
|
+
const conversationExchangeByKey = new Map();
|
|
26
55
|
function setDiscordPresence(userId, data) {
|
|
27
56
|
discordPresenceCache.set(userId, data);
|
|
28
57
|
if (discordPresenceCache.size > MAX_PRESENCE_CACHE_USERS) {
|
|
@@ -90,7 +119,8 @@ function buildPendingHistoryContext(entries) {
|
|
|
90
119
|
const line = summarizePendingHistoryEntry(entries[i]);
|
|
91
120
|
if (!line)
|
|
92
121
|
continue;
|
|
93
|
-
if (totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS &&
|
|
122
|
+
if (totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS &&
|
|
123
|
+
selected.length > 0)
|
|
94
124
|
break;
|
|
95
125
|
selected.push(line);
|
|
96
126
|
totalChars += line.length + 1;
|
|
@@ -110,7 +140,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
|
|
|
110
140
|
if (!msg.guild || !('messages' in msg.channel))
|
|
111
141
|
return { entries: [], context: '' };
|
|
112
142
|
try {
|
|
113
|
-
const recentMessages = await msg.channel.messages.fetch({
|
|
143
|
+
const recentMessages = await msg.channel.messages.fetch({
|
|
144
|
+
limit: GUILD_INBOUND_HISTORY_LIMIT,
|
|
145
|
+
});
|
|
114
146
|
const entries = [];
|
|
115
147
|
let hiddenTextCount = 0;
|
|
116
148
|
let hiddenBotTextCount = 0;
|
|
@@ -119,7 +151,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
|
|
|
119
151
|
if (plainText)
|
|
120
152
|
return plainText;
|
|
121
153
|
const embedChunks = recent.embeds
|
|
122
|
-
.map((embed) => [embed.title?.trim(), embed.description?.trim()]
|
|
154
|
+
.map((embed) => [embed.title?.trim(), embed.description?.trim()]
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join(' — '))
|
|
123
157
|
.map((part) => part.trim())
|
|
124
158
|
.filter(Boolean)
|
|
125
159
|
.slice(0, 3);
|
|
@@ -133,7 +167,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
|
|
|
133
167
|
if (attachmentNames.length > 0) {
|
|
134
168
|
return `[attachments] ${attachmentNames.join(', ')}`;
|
|
135
169
|
}
|
|
136
|
-
const systemContent = recent.system
|
|
170
|
+
const systemContent = recent.system
|
|
171
|
+
? (recent.cleanContent || '').trim()
|
|
172
|
+
: '';
|
|
137
173
|
if (systemContent)
|
|
138
174
|
return `[system] ${systemContent}`;
|
|
139
175
|
hiddenTextCount += 1;
|
|
@@ -157,7 +193,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
|
|
|
157
193
|
username: recent.author.username || 'user',
|
|
158
194
|
displayName: recent.member?.displayName || null,
|
|
159
195
|
isBot: Boolean(recent.author.bot),
|
|
160
|
-
timestampMs: Number.isFinite(recent.createdTimestamp)
|
|
196
|
+
timestampMs: Number.isFinite(recent.createdTimestamp)
|
|
197
|
+
? recent.createdTimestamp
|
|
198
|
+
: 0,
|
|
161
199
|
content,
|
|
162
200
|
});
|
|
163
201
|
}
|
|
@@ -314,29 +352,101 @@ function isAuthorizedCommandUserId(userId) {
|
|
|
314
352
|
function buildSessionIdFromContext(guildId, channelId, userId) {
|
|
315
353
|
return buildSessionIdFromContextInbound(guildId, channelId, userId);
|
|
316
354
|
}
|
|
317
|
-
function
|
|
355
|
+
function resolveGuildMessageMode(msg) {
|
|
356
|
+
if (!msg.guild)
|
|
357
|
+
return 'free';
|
|
358
|
+
if (DISCORD_GROUP_POLICY === 'disabled')
|
|
359
|
+
return 'off';
|
|
360
|
+
const guildConfig = DISCORD_GUILDS[msg.guild.id];
|
|
361
|
+
const explicitMode = guildConfig?.channels[msg.channelId]?.mode;
|
|
362
|
+
if (DISCORD_GROUP_POLICY === 'allowlist') {
|
|
363
|
+
return explicitMode ?? 'off';
|
|
364
|
+
}
|
|
365
|
+
if (explicitMode)
|
|
366
|
+
return explicitMode;
|
|
367
|
+
if (DISCORD_FREE_RESPONSE_CHANNELS.includes(msg.channelId))
|
|
368
|
+
return 'free';
|
|
369
|
+
if (guildConfig)
|
|
370
|
+
return guildConfig.defaultMode;
|
|
371
|
+
if (DISCORD_RESPOND_TO_ALL_MESSAGES)
|
|
372
|
+
return 'free';
|
|
373
|
+
return 'mention';
|
|
374
|
+
}
|
|
375
|
+
function resolveChannelBehavior(msg) {
|
|
376
|
+
const guildConfig = msg.guild ? DISCORD_GUILDS[msg.guild.id] : undefined;
|
|
377
|
+
const channelConfig = guildConfig?.channels[msg.channelId];
|
|
378
|
+
return {
|
|
379
|
+
guildMessageMode: resolveGuildMessageMode(msg),
|
|
380
|
+
typingMode: channelConfig?.typingMode ?? DISCORD_TYPING_MODE,
|
|
381
|
+
debounceMs: resolveInboundDebounceMs(DISCORD_DEBOUNCE_MS, channelConfig?.debounceMs),
|
|
382
|
+
ackReaction: (channelConfig?.ackReaction ?? DISCORD_ACK_REACTION).trim() ||
|
|
383
|
+
DISCORD_ACK_REACTION,
|
|
384
|
+
ackReactionScope: channelConfig?.ackReactionScope ?? DISCORD_ACK_REACTION_SCOPE,
|
|
385
|
+
removeAckAfterReply: channelConfig?.removeAckAfterReply ?? DISCORD_REMOVE_ACK_AFTER_REPLY,
|
|
386
|
+
humanDelay: channelConfig?.humanDelay ?? DISCORD_HUMAN_DELAY,
|
|
387
|
+
rateLimitPerUser: Math.max(0, channelConfig?.rateLimitPerUser ?? DISCORD_RATE_LIMIT_PER_USER),
|
|
388
|
+
suppressPatterns: channelConfig?.suppressPatterns ?? DISCORD_SUPPRESS_PATTERNS,
|
|
389
|
+
maxConcurrentPerChannel: Math.max(1, channelConfig?.maxConcurrentPerChannel ??
|
|
390
|
+
DISCORD_MAX_CONCURRENT_PER_CHANNEL),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function isTrigger(msg, behavior) {
|
|
318
394
|
return isTriggerInbound({
|
|
319
395
|
content: msg.content,
|
|
320
396
|
isDm: !msg.guild,
|
|
321
397
|
commandsOnly: DISCORD_COMMANDS_ONLY,
|
|
322
398
|
respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
|
|
399
|
+
guildMessageMode: behavior.guildMessageMode,
|
|
323
400
|
prefix: DISCORD_PREFIX,
|
|
324
401
|
botMentionRegex,
|
|
325
402
|
hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
|
|
403
|
+
suppressPatterns: behavior.suppressPatterns,
|
|
326
404
|
});
|
|
327
405
|
}
|
|
406
|
+
function shouldApplyAckReaction(msg, behavior) {
|
|
407
|
+
const scope = behavior.ackReactionScope;
|
|
408
|
+
if (scope === 'off')
|
|
409
|
+
return false;
|
|
410
|
+
if (scope === 'all')
|
|
411
|
+
return true;
|
|
412
|
+
if (scope === 'direct')
|
|
413
|
+
return !msg.guild;
|
|
414
|
+
if (!msg.guild || !client.user)
|
|
415
|
+
return false;
|
|
416
|
+
return msg.mentions.has(client.user);
|
|
417
|
+
}
|
|
418
|
+
function isRateLimitExempt(msg) {
|
|
419
|
+
if (msg.author.id === DISCORD_COMMAND_USER_ID.trim())
|
|
420
|
+
return true;
|
|
421
|
+
if (!msg.guild)
|
|
422
|
+
return false;
|
|
423
|
+
if (!msg.member || DISCORD_RATE_LIMIT_EXEMPT_ROLES.length === 0)
|
|
424
|
+
return false;
|
|
425
|
+
const exemptByName = new Set(DISCORD_RATE_LIMIT_EXEMPT_ROLES.map((role) => role.trim().toLowerCase()).filter(Boolean));
|
|
426
|
+
if (exemptByName.size === 0)
|
|
427
|
+
return false;
|
|
428
|
+
for (const role of msg.member.roles.cache.values()) {
|
|
429
|
+
const normalized = role.name.trim().toLowerCase();
|
|
430
|
+
if (exemptByName.has(normalized))
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
328
435
|
function parseCommand(content) {
|
|
329
436
|
return parseCommandInbound(content, botMentionRegex, DISCORD_PREFIX);
|
|
330
437
|
}
|
|
331
438
|
function isRetryableDiscordError(error) {
|
|
332
439
|
const maybe = error;
|
|
333
440
|
const status = maybe.status ?? maybe.httpStatus;
|
|
334
|
-
return status === 429 ||
|
|
441
|
+
return (status === 429 ||
|
|
442
|
+
(typeof status === 'number' && status >= 500 && status <= 599));
|
|
335
443
|
}
|
|
336
444
|
function retryDelayMs(error, fallbackMs) {
|
|
337
445
|
const maybe = error;
|
|
338
446
|
const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
|
|
339
|
-
if (typeof retryAfterSeconds === 'number' &&
|
|
447
|
+
if (typeof retryAfterSeconds === 'number' &&
|
|
448
|
+
Number.isFinite(retryAfterSeconds) &&
|
|
449
|
+
retryAfterSeconds > 0) {
|
|
340
450
|
return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
|
|
341
451
|
}
|
|
342
452
|
return fallbackMs + Math.floor(Math.random() * 250);
|
|
@@ -350,7 +460,8 @@ async function withDiscordRetry(label, fn) {
|
|
|
350
460
|
return await fn();
|
|
351
461
|
}
|
|
352
462
|
catch (error) {
|
|
353
|
-
if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS ||
|
|
463
|
+
if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS ||
|
|
464
|
+
!isRetryableDiscordError(error)) {
|
|
354
465
|
throw error;
|
|
355
466
|
}
|
|
356
467
|
const waitMs = retryDelayMs(error, delayMs);
|
|
@@ -386,7 +497,9 @@ function buildChannelInfoContext(msg) {
|
|
|
386
497
|
if (channelTopic) {
|
|
387
498
|
lines.push(`- channel_topic: ${channelTopic}`);
|
|
388
499
|
}
|
|
389
|
-
const parentName = typeof namedChannel.parent?.name === 'string'
|
|
500
|
+
const parentName = typeof namedChannel.parent?.name === 'string'
|
|
501
|
+
? namedChannel.parent.name.trim()
|
|
502
|
+
: '';
|
|
390
503
|
if (parentName) {
|
|
391
504
|
lines.push(`- parent_channel: ${parentName}`);
|
|
392
505
|
}
|
|
@@ -395,7 +508,9 @@ function buildChannelInfoContext(msg) {
|
|
|
395
508
|
}
|
|
396
509
|
async function buildReplyContext(msg) {
|
|
397
510
|
const blocks = [];
|
|
398
|
-
if ('isThread' in msg.channel &&
|
|
511
|
+
if ('isThread' in msg.channel &&
|
|
512
|
+
typeof msg.channel.isThread === 'function' &&
|
|
513
|
+
msg.channel.isThread()) {
|
|
399
514
|
try {
|
|
400
515
|
const starter = await msg.channel.fetchStarterMessage();
|
|
401
516
|
if (starter) {
|
|
@@ -427,70 +542,22 @@ async function buildReplyContext(msg) {
|
|
|
427
542
|
return '';
|
|
428
543
|
return `${blocks.join('\n\n')}\n\n`;
|
|
429
544
|
}
|
|
430
|
-
async function
|
|
431
|
-
if (!client.user)
|
|
432
|
-
return async () => { };
|
|
433
|
-
const botUserId = client.user.id;
|
|
434
|
-
try {
|
|
435
|
-
await withDiscordRetry('react', () => msg.react('👀'));
|
|
436
|
-
}
|
|
437
|
-
catch (error) {
|
|
438
|
-
logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to add processing reaction');
|
|
439
|
-
return async () => { };
|
|
440
|
-
}
|
|
441
|
-
return async () => {
|
|
442
|
-
try {
|
|
443
|
-
const reaction = msg.reactions.resolve('👀');
|
|
444
|
-
if (!reaction)
|
|
445
|
-
return;
|
|
446
|
-
await withDiscordRetry('reaction-remove', () => reaction.users.remove(botUserId));
|
|
447
|
-
}
|
|
448
|
-
catch (error) {
|
|
449
|
-
logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to remove processing reaction');
|
|
450
|
-
}
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
function startTypingLoop(msg) {
|
|
454
|
-
let stopped = false;
|
|
455
|
-
const sendTyping = async () => {
|
|
456
|
-
if (stopped)
|
|
457
|
-
return;
|
|
458
|
-
if (!('sendTyping' in msg.channel))
|
|
459
|
-
return;
|
|
460
|
-
try {
|
|
461
|
-
await msg.channel.sendTyping();
|
|
462
|
-
}
|
|
463
|
-
catch (error) {
|
|
464
|
-
logger.debug({ error, channelId: msg.channelId }, 'Failed to send typing indicator');
|
|
465
|
-
}
|
|
466
|
-
};
|
|
467
|
-
void sendTyping();
|
|
468
|
-
const timer = setInterval(() => {
|
|
469
|
-
void sendTyping();
|
|
470
|
-
}, 8_000);
|
|
471
|
-
return {
|
|
472
|
-
stop: () => {
|
|
473
|
-
if (stopped)
|
|
474
|
-
return;
|
|
475
|
-
stopped = true;
|
|
476
|
-
clearInterval(timer);
|
|
477
|
-
},
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
async function sendChunkedReply(msg, text, files, mentionLookup) {
|
|
545
|
+
async function sendChunkedReply(msg, text, files, mentionLookup, humanDelay) {
|
|
481
546
|
await sendChunkedReplyFromDelivery({
|
|
482
547
|
msg,
|
|
483
548
|
text,
|
|
484
549
|
withRetry: withDiscordRetry,
|
|
550
|
+
...(humanDelay ? { humanDelay } : {}),
|
|
485
551
|
...(files ? { files } : {}),
|
|
486
552
|
...(mentionLookup ? { mentionLookup } : {}),
|
|
487
553
|
});
|
|
488
554
|
}
|
|
489
|
-
async function sendChunkedDirectReply(msg, text, files, mentionLookup) {
|
|
555
|
+
async function sendChunkedDirectReply(msg, text, files, mentionLookup, humanDelay) {
|
|
490
556
|
await sendChunkedDirectReplyFromDelivery({
|
|
491
557
|
msg,
|
|
492
558
|
text,
|
|
493
559
|
withRetry: withDiscordRetry,
|
|
560
|
+
...(humanDelay ? { humanDelay } : {}),
|
|
494
561
|
...(files ? { files } : {}),
|
|
495
562
|
...(mentionLookup ? { mentionLookup } : {}),
|
|
496
563
|
});
|
|
@@ -503,52 +570,177 @@ async function sendChunkedInteractionReply(interaction, text, files) {
|
|
|
503
570
|
...(files ? { files } : {}),
|
|
504
571
|
});
|
|
505
572
|
}
|
|
506
|
-
async function
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
573
|
+
async function ensureSlashCommands() {
|
|
574
|
+
const definitions = [
|
|
575
|
+
{
|
|
576
|
+
name: 'status',
|
|
577
|
+
description: 'Show HybridClaw runtime status (only visible to you)',
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
name: 'channel-mode',
|
|
581
|
+
description: 'Set this channel to off, mention-only, or free-response',
|
|
582
|
+
options: [
|
|
583
|
+
{
|
|
584
|
+
type: ApplicationCommandOptionType.String,
|
|
585
|
+
name: 'mode',
|
|
586
|
+
description: 'Response mode for this channel',
|
|
587
|
+
required: true,
|
|
588
|
+
choices: [
|
|
589
|
+
{ name: 'off', value: 'off' },
|
|
590
|
+
{ name: 'mention', value: 'mention' },
|
|
591
|
+
{ name: 'free', value: 'free' },
|
|
592
|
+
],
|
|
593
|
+
},
|
|
594
|
+
],
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
name: 'channel-policy',
|
|
598
|
+
description: 'Set guild channel policy to open, allowlist, or disabled',
|
|
599
|
+
options: [
|
|
600
|
+
{
|
|
601
|
+
type: ApplicationCommandOptionType.String,
|
|
602
|
+
name: 'policy',
|
|
603
|
+
description: 'Guild channel policy',
|
|
604
|
+
required: true,
|
|
605
|
+
choices: [
|
|
606
|
+
{ name: 'open', value: 'open' },
|
|
607
|
+
{ name: 'allowlist', value: 'allowlist' },
|
|
608
|
+
{ name: 'disabled', value: 'disabled' },
|
|
609
|
+
],
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
},
|
|
613
|
+
];
|
|
511
614
|
if (!client.application)
|
|
512
615
|
return;
|
|
513
616
|
await Promise.allSettled([...client.guilds.cache.values()].map(async (guild) => {
|
|
514
617
|
try {
|
|
515
618
|
const existing = await guild.commands.fetch();
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
619
|
+
for (const definition of definitions) {
|
|
620
|
+
const current = existing.find((command) => command.name === definition.name);
|
|
621
|
+
if (!current) {
|
|
622
|
+
await guild.commands.create(definition);
|
|
623
|
+
logger.info({ guildId: guild.id, command: definition.name }, 'Registered slash command');
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
523
626
|
await guild.commands.edit(current.id, definition);
|
|
524
|
-
logger.info({ guildId: guild.id }, 'Updated slash command
|
|
627
|
+
logger.info({ guildId: guild.id, command: definition.name }, 'Updated slash command');
|
|
525
628
|
}
|
|
526
629
|
}
|
|
527
630
|
catch (error) {
|
|
528
|
-
logger.warn({ error, guildId: guild.id }, 'Failed to register slash
|
|
631
|
+
logger.warn({ error, guildId: guild.id }, 'Failed to register Discord slash commands');
|
|
529
632
|
}
|
|
530
633
|
}));
|
|
531
634
|
}
|
|
532
|
-
function
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
635
|
+
function trimRecentConversationMetrics(nowMs = Date.now()) {
|
|
636
|
+
const cutoff = nowMs - PRESENCE_WINDOW_MS;
|
|
637
|
+
while (recentConversationMetrics.length > 0 &&
|
|
638
|
+
recentConversationMetrics[0].atMs < cutoff) {
|
|
639
|
+
recentConversationMetrics.shift();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function recordConversationMetric(params) {
|
|
643
|
+
const nowMs = Date.now();
|
|
644
|
+
const errorText = params.error instanceof Error
|
|
645
|
+
? params.error.message
|
|
646
|
+
: String(params.error || '');
|
|
647
|
+
const exhaustedHint = !params.ok && PRESENCE_EXHAUSTED_ERROR_RE.test(errorText);
|
|
648
|
+
if (params.ok) {
|
|
649
|
+
consecutiveConversationFailures = 0;
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
consecutiveConversationFailures += 1;
|
|
653
|
+
}
|
|
654
|
+
recentConversationMetrics.push({
|
|
655
|
+
atMs: nowMs,
|
|
656
|
+
durationMs: Math.max(0, Math.floor(params.durationMs)),
|
|
657
|
+
ok: params.ok,
|
|
658
|
+
exhaustedHint,
|
|
659
|
+
});
|
|
660
|
+
trimRecentConversationMetrics(nowMs);
|
|
661
|
+
}
|
|
662
|
+
function resolvePresenceHealthState() {
|
|
663
|
+
trimRecentConversationMetrics();
|
|
664
|
+
if (activeConversationRuns >= 4)
|
|
665
|
+
return 'degraded';
|
|
666
|
+
if (consecutiveConversationFailures >= 3)
|
|
667
|
+
return 'exhausted';
|
|
668
|
+
if (recentConversationMetrics.some((entry) => entry.exhaustedHint))
|
|
669
|
+
return 'exhausted';
|
|
670
|
+
if (recentConversationMetrics.length === 0)
|
|
671
|
+
return 'healthy';
|
|
672
|
+
const totalDuration = recentConversationMetrics.reduce((sum, entry) => sum + entry.durationMs, 0);
|
|
673
|
+
const avgDuration = totalDuration / recentConversationMetrics.length;
|
|
674
|
+
const failureCount = recentConversationMetrics.filter((entry) => !entry.ok).length;
|
|
675
|
+
const errorRate = failureCount / recentConversationMetrics.length;
|
|
676
|
+
if (errorRate >= 0.25 || avgDuration >= PRESENCE_DEGRADED_DURATION_MS)
|
|
677
|
+
return 'degraded';
|
|
678
|
+
return 'healthy';
|
|
679
|
+
}
|
|
680
|
+
function randomIntInRange(minMs, maxMs) {
|
|
681
|
+
const lo = Math.floor(Math.max(0, minMs));
|
|
682
|
+
const hi = Math.floor(Math.max(lo, maxMs));
|
|
683
|
+
if (hi <= lo)
|
|
684
|
+
return lo;
|
|
685
|
+
return lo + Math.floor(Math.random() * (hi - lo + 1));
|
|
686
|
+
}
|
|
687
|
+
function isNightOrWeekend(now) {
|
|
688
|
+
const day = now.getDay();
|
|
689
|
+
const hour = now.getHours();
|
|
690
|
+
const weekend = day === 0 || day === 6;
|
|
691
|
+
const night = hour >= NIGHT_HOURS_START || hour < NIGHT_HOURS_END;
|
|
692
|
+
return weekend || night;
|
|
693
|
+
}
|
|
694
|
+
function buildConversationCooldownKey(channelId, userId) {
|
|
695
|
+
return `${channelId}:${userId}`;
|
|
696
|
+
}
|
|
697
|
+
function resolveHumanDelayWithBehavior(base, cooldownKey) {
|
|
698
|
+
if (base.mode === 'off')
|
|
699
|
+
return base;
|
|
700
|
+
const now = new Date();
|
|
701
|
+
let factor = isNightOrWeekend(now) ? 1.5 : 1;
|
|
702
|
+
const record = conversationExchangeByKey.get(cooldownKey);
|
|
703
|
+
if (record) {
|
|
704
|
+
const elapsedMs = Date.now() - record.lastAtMs;
|
|
705
|
+
if (elapsedMs > CONVERSATION_COOLDOWN_RESET_MS) {
|
|
706
|
+
conversationExchangeByKey.delete(cooldownKey);
|
|
707
|
+
}
|
|
708
|
+
else if (record.count > CONVERSATION_COOLDOWN_THRESHOLD) {
|
|
709
|
+
const extra = Math.min(CONVERSATION_COOLDOWN_MAX_FACTOR - 1, (record.count - CONVERSATION_COOLDOWN_THRESHOLD) * 0.12);
|
|
710
|
+
factor += Math.max(0, extra);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (factor <= 1)
|
|
714
|
+
return base;
|
|
715
|
+
const minMs = Math.round((base.minMs ?? 800) * factor);
|
|
716
|
+
const maxMs = Math.round((base.maxMs ?? 2_500) * factor);
|
|
717
|
+
return {
|
|
718
|
+
mode: 'custom',
|
|
719
|
+
minMs,
|
|
720
|
+
maxMs: Math.max(minMs, maxMs),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
function noteConversationExchange(cooldownKey) {
|
|
724
|
+
const nowMs = Date.now();
|
|
725
|
+
const existing = conversationExchangeByKey.get(cooldownKey);
|
|
726
|
+
if (!existing || nowMs - existing.lastAtMs > CONVERSATION_COOLDOWN_RESET_MS) {
|
|
727
|
+
conversationExchangeByKey.set(cooldownKey, { count: 1, lastAtMs: nowMs });
|
|
540
728
|
return;
|
|
541
729
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
730
|
+
conversationExchangeByKey.set(cooldownKey, {
|
|
731
|
+
count: existing.count + 1,
|
|
732
|
+
lastAtMs: nowMs,
|
|
545
733
|
});
|
|
546
734
|
}
|
|
735
|
+
function pickReadWithoutReplyEmoji() {
|
|
736
|
+
return Math.random() < 0.7 ? '👍' : '✅';
|
|
737
|
+
}
|
|
547
738
|
export function initDiscord(onMessage, onCommand) {
|
|
548
739
|
messageHandler = onMessage;
|
|
549
740
|
commandHandler = onCommand;
|
|
550
741
|
const pendingBatches = new Map();
|
|
551
742
|
const inFlightByMessageId = new Map();
|
|
743
|
+
const channelConcurrencyById = new Map();
|
|
552
744
|
const negativeFeedbackByChannel = new Map();
|
|
553
745
|
const participantMemoryByChannel = new Map();
|
|
554
746
|
const touchParticipantMemoryChannel = (channelId) => {
|
|
@@ -623,6 +815,99 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
623
815
|
rememberParticipantAliasForChannel(msg.channelId, hint.userId, hint.alias);
|
|
624
816
|
}
|
|
625
817
|
};
|
|
818
|
+
const waitForChannelConcurrencySlot = async (channelId, maxConcurrent, abortSignal) => {
|
|
819
|
+
const boundedMax = Math.max(1, Math.floor(maxConcurrent));
|
|
820
|
+
while ((channelConcurrencyById.get(channelId) ?? 0) >= boundedMax) {
|
|
821
|
+
if (abortSignal?.aborted) {
|
|
822
|
+
throw new Error('Conversation aborted while waiting for channel concurrency slot.');
|
|
823
|
+
}
|
|
824
|
+
await new Promise((resolve) => setTimeout(resolve, CONCURRENCY_RETRY_DELAY_MS));
|
|
825
|
+
}
|
|
826
|
+
if (abortSignal?.aborted) {
|
|
827
|
+
throw new Error('Conversation aborted before acquiring channel concurrency slot.');
|
|
828
|
+
}
|
|
829
|
+
channelConcurrencyById.set(channelId, (channelConcurrencyById.get(channelId) ?? 0) + 1);
|
|
830
|
+
return () => {
|
|
831
|
+
const current = channelConcurrencyById.get(channelId) ?? 0;
|
|
832
|
+
const next = Math.max(0, current - 1);
|
|
833
|
+
if (next === 0) {
|
|
834
|
+
channelConcurrencyById.delete(channelId);
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
channelConcurrencyById.set(channelId, next);
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
};
|
|
841
|
+
const enforcePerUserRateLimit = async (msg, behavior) => {
|
|
842
|
+
const limit = Math.max(0, Math.floor(behavior.rateLimitPerUser));
|
|
843
|
+
if (limit === 0)
|
|
844
|
+
return true;
|
|
845
|
+
if (isRateLimitExempt(msg))
|
|
846
|
+
return true;
|
|
847
|
+
const key = `${msg.channelId}:${msg.author.id}`;
|
|
848
|
+
const decision = userRateLimiter.check(key, limit);
|
|
849
|
+
if (decision.allowed)
|
|
850
|
+
return true;
|
|
851
|
+
if (userRateLimiter.shouldNotify(key, RATE_LIMIT_NOTIFY_COOLDOWN_MS)) {
|
|
852
|
+
try {
|
|
853
|
+
await withDiscordRetry('rate-limit-reply', () => msg.reply({ content: FRIENDLY_RATE_LIMIT_MESSAGE }));
|
|
854
|
+
}
|
|
855
|
+
catch (error) {
|
|
856
|
+
logger.debug({ error, channelId: msg.channelId, userId: msg.author.id }, 'Failed to send rate-limit warning');
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return false;
|
|
860
|
+
};
|
|
861
|
+
const maybeHandleReadWithoutReply = async (msg, content) => {
|
|
862
|
+
if (!content.trim())
|
|
863
|
+
return false;
|
|
864
|
+
if (msg.attachments.size > 0)
|
|
865
|
+
return false;
|
|
866
|
+
if (hasPrefixInvocation(msg.content || ''))
|
|
867
|
+
return false;
|
|
868
|
+
if (client.user && msg.mentions.has(client.user))
|
|
869
|
+
return false;
|
|
870
|
+
if (content.trim().length > 80)
|
|
871
|
+
return false;
|
|
872
|
+
if (!READ_WITHOUT_REPLY_RE.test(content.trim()))
|
|
873
|
+
return false;
|
|
874
|
+
if (Math.random() > READ_WITHOUT_REPLY_PROBABILITY)
|
|
875
|
+
return false;
|
|
876
|
+
try {
|
|
877
|
+
await withDiscordRetry('reaction-read-without-reply', () => msg.react(pickReadWithoutReplyEmoji()));
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
catch (error) {
|
|
881
|
+
logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed read-without-reply reaction');
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
const shouldSelectivelySilence = (params) => {
|
|
886
|
+
if (!params.sourceItem.msg.guild)
|
|
887
|
+
return false;
|
|
888
|
+
if (params.behavior.guildMessageMode !== 'free')
|
|
889
|
+
return false;
|
|
890
|
+
if (params.sourceItem.wasExplicitlyAddressed)
|
|
891
|
+
return false;
|
|
892
|
+
const nowMs = Date.now();
|
|
893
|
+
const peerMessages = params.inboundHistory
|
|
894
|
+
.filter((entry) => !entry.isBot &&
|
|
895
|
+
entry.userId !== params.sourceItem.msg.author.id &&
|
|
896
|
+
nowMs - entry.timestampMs <= SELECTIVE_SILENCE_RECENT_WINDOW_MS)
|
|
897
|
+
.sort((a, b) => a.timestampMs - b.timestampMs);
|
|
898
|
+
if (peerMessages.length === 0)
|
|
899
|
+
return false;
|
|
900
|
+
const sourceText = params.sourceItem.content.trim();
|
|
901
|
+
const asksQuestion = sourceText.includes('?');
|
|
902
|
+
const latestPeer = peerMessages[peerMessages.length - 1]?.content?.trim().toLowerCase() ||
|
|
903
|
+
'';
|
|
904
|
+
const peerLooksLikeAnswer = latestPeer.length >= 24 ||
|
|
905
|
+
/\\b(you can|try|use|it is|it's|because|should|here|answer|fix)\\b/.test(latestPeer);
|
|
906
|
+
const probability = asksQuestion && peerLooksLikeAnswer
|
|
907
|
+
? SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY
|
|
908
|
+
: SELECTIVE_SILENCE_BASE_PROBABILITY;
|
|
909
|
+
return Math.random() < probability;
|
|
910
|
+
};
|
|
626
911
|
const intents = [
|
|
627
912
|
GatewayIntentBits.Guilds,
|
|
628
913
|
GatewayIntentBits.GuildMessages,
|
|
@@ -636,7 +921,12 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
636
921
|
intents.push(GatewayIntentBits.GuildPresences);
|
|
637
922
|
client = new Client({
|
|
638
923
|
intents,
|
|
639
|
-
partials: [
|
|
924
|
+
partials: [
|
|
925
|
+
Partials.Channel,
|
|
926
|
+
Partials.Message,
|
|
927
|
+
Partials.Reaction,
|
|
928
|
+
Partials.User,
|
|
929
|
+
],
|
|
640
930
|
});
|
|
641
931
|
client.on('presenceUpdate', (_oldPresence, nextPresence) => {
|
|
642
932
|
const userId = nextPresence.userId || nextPresence.user?.id;
|
|
@@ -654,17 +944,27 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
654
944
|
});
|
|
655
945
|
client.on('clientReady', () => {
|
|
656
946
|
logger.info({ user: client.user?.tag }, 'Discord bot connected');
|
|
947
|
+
startupConnectedAtMs = Date.now();
|
|
657
948
|
if (client.user) {
|
|
658
949
|
botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
|
|
659
950
|
}
|
|
660
|
-
|
|
661
|
-
|
|
951
|
+
presenceController?.stop();
|
|
952
|
+
presenceController = new DiscordAutoPresenceController({
|
|
953
|
+
client,
|
|
954
|
+
getConfig: () => DISCORD_SELF_PRESENCE,
|
|
955
|
+
resolveState: resolvePresenceHealthState,
|
|
956
|
+
});
|
|
957
|
+
presenceController.start();
|
|
958
|
+
void ensureSlashCommands();
|
|
662
959
|
});
|
|
663
960
|
client.on('interactionCreate', async (interaction) => {
|
|
664
961
|
if (!interaction.isChatInputCommand())
|
|
665
962
|
return;
|
|
666
|
-
if (interaction.commandName !== 'status'
|
|
963
|
+
if (interaction.commandName !== 'status' &&
|
|
964
|
+
interaction.commandName !== 'channel-mode' &&
|
|
965
|
+
interaction.commandName !== 'channel-policy') {
|
|
667
966
|
return;
|
|
967
|
+
}
|
|
668
968
|
if (!isAuthorizedCommandUserId(interaction.user.id)) {
|
|
669
969
|
await sendChunkedInteractionReply(interaction, 'You are not authorized to run commands for this bot.');
|
|
670
970
|
return;
|
|
@@ -672,12 +972,45 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
672
972
|
const guildId = interaction.guildId ?? null;
|
|
673
973
|
const channelId = interaction.channelId;
|
|
674
974
|
const sessionId = buildSessionIdFromContext(guildId, channelId, interaction.user.id);
|
|
975
|
+
const args = interaction.commandName === 'status'
|
|
976
|
+
? ['status']
|
|
977
|
+
: interaction.commandName === 'channel-mode'
|
|
978
|
+
? (() => {
|
|
979
|
+
if (!interaction.guildId)
|
|
980
|
+
return null;
|
|
981
|
+
const selectedMode = interaction.options
|
|
982
|
+
.getString('mode', true)
|
|
983
|
+
.trim()
|
|
984
|
+
.toLowerCase();
|
|
985
|
+
if (selectedMode !== 'off' &&
|
|
986
|
+
selectedMode !== 'mention' &&
|
|
987
|
+
selectedMode !== 'free')
|
|
988
|
+
return null;
|
|
989
|
+
return ['channel', 'mode', selectedMode];
|
|
990
|
+
})()
|
|
991
|
+
: (() => {
|
|
992
|
+
if (!interaction.guildId)
|
|
993
|
+
return null;
|
|
994
|
+
const selectedPolicy = interaction.options
|
|
995
|
+
.getString('policy', true)
|
|
996
|
+
.trim()
|
|
997
|
+
.toLowerCase();
|
|
998
|
+
if (selectedPolicy !== 'open' &&
|
|
999
|
+
selectedPolicy !== 'allowlist' &&
|
|
1000
|
+
selectedPolicy !== 'disabled')
|
|
1001
|
+
return null;
|
|
1002
|
+
return ['channel', 'policy', selectedPolicy];
|
|
1003
|
+
})();
|
|
1004
|
+
if (!args) {
|
|
1005
|
+
await sendChunkedInteractionReply(interaction, 'This command can only be used in a server channel with a valid mode/policy option.');
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
675
1008
|
try {
|
|
676
|
-
await commandHandler(sessionId, guildId, channelId,
|
|
1009
|
+
await commandHandler(sessionId, guildId, channelId, args, async (text, files) => sendChunkedInteractionReply(interaction, text, files));
|
|
677
1010
|
}
|
|
678
1011
|
catch (error) {
|
|
679
1012
|
const detail = error instanceof Error ? error.message : String(error);
|
|
680
|
-
logger.error({ error, guildId, channelId, userId: interaction.user.id }, 'Discord slash
|
|
1013
|
+
logger.error({ error, guildId, channelId, userId: interaction.user.id }, 'Discord slash command failed');
|
|
681
1014
|
await sendChunkedInteractionReply(interaction, formatError('Gateway Error', detail));
|
|
682
1015
|
}
|
|
683
1016
|
});
|
|
@@ -696,8 +1029,13 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
696
1029
|
const channelId = msg.channelId;
|
|
697
1030
|
const userId = msg.author.id;
|
|
698
1031
|
const username = msg.author.username;
|
|
1032
|
+
const behavior = sourceItem.behavior;
|
|
1033
|
+
const startedAt = Date.now();
|
|
1034
|
+
let releaseChannelSlot = null;
|
|
699
1035
|
const batchedContent = items.length > 1
|
|
700
|
-
? items
|
|
1036
|
+
? items
|
|
1037
|
+
.map((item, index) => `Message ${index + 1}:\n${item.content}`)
|
|
1038
|
+
.join('\n\n')
|
|
701
1039
|
: sourceItem.content;
|
|
702
1040
|
const channelInfoContext = buildChannelInfoContext(msg);
|
|
703
1041
|
const replyContext = await buildReplyContext(msg);
|
|
@@ -712,78 +1050,205 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
712
1050
|
const participantContext = buildParticipantContext(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
|
|
713
1051
|
const mentionLookup = buildMentionLookup(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
|
|
714
1052
|
const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext.context}${participantContext}${batchedContent}`;
|
|
1053
|
+
const selectiveSilence = shouldSelectivelySilence({
|
|
1054
|
+
sourceItem,
|
|
1055
|
+
inboundHistory: inboundHistory.entries,
|
|
1056
|
+
behavior,
|
|
1057
|
+
});
|
|
715
1058
|
const abortController = new AbortController();
|
|
716
|
-
const
|
|
1059
|
+
const typingController = pending.typingController;
|
|
1060
|
+
const lifecycleController = pending.lifecycleController;
|
|
1061
|
+
const emitLifecyclePhase = (phase) => {
|
|
1062
|
+
if (phase === 'queued') {
|
|
1063
|
+
typingController.setPhase('received');
|
|
1064
|
+
}
|
|
1065
|
+
else if (phase === 'thinking') {
|
|
1066
|
+
typingController.setPhase('thinking');
|
|
1067
|
+
}
|
|
1068
|
+
else if (phase === 'toolUse') {
|
|
1069
|
+
typingController.setPhase('toolUse');
|
|
1070
|
+
}
|
|
1071
|
+
else if (phase === 'streaming') {
|
|
1072
|
+
typingController.setPhase('streaming');
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
typingController.setPhase('done');
|
|
1076
|
+
}
|
|
1077
|
+
lifecycleController?.setPhase(phase);
|
|
1078
|
+
};
|
|
717
1079
|
const stream = new DiscordStreamManager(msg, {
|
|
718
|
-
onFirstMessage: () =>
|
|
1080
|
+
onFirstMessage: () => emitLifecyclePhase('streaming'),
|
|
1081
|
+
humanDelay: behavior.humanDelay,
|
|
719
1082
|
});
|
|
720
1083
|
const inFlight = {
|
|
721
1084
|
abortController,
|
|
722
1085
|
stream,
|
|
723
1086
|
messageIds: new Set(items.map((item) => item.msg.id)),
|
|
724
1087
|
aborted: false,
|
|
1088
|
+
emitLifecyclePhase,
|
|
725
1089
|
};
|
|
726
1090
|
for (const messageId of inFlight.messageIds) {
|
|
727
1091
|
inFlightByMessageId.set(messageId, inFlight);
|
|
728
1092
|
}
|
|
729
1093
|
try {
|
|
1094
|
+
if (selectiveSilence) {
|
|
1095
|
+
emitLifecyclePhase('done');
|
|
1096
|
+
if (Math.random() < 0.5) {
|
|
1097
|
+
await withDiscordRetry('reaction-selective-silence', () => msg.react(pickReadWithoutReplyEmoji())).catch(() => { });
|
|
1098
|
+
}
|
|
1099
|
+
recordConversationMetric({
|
|
1100
|
+
durationMs: Date.now() - startedAt,
|
|
1101
|
+
ok: true,
|
|
1102
|
+
});
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
releaseChannelSlot = await waitForChannelConcurrencySlot(channelId, behavior.maxConcurrentPerChannel, abortController.signal);
|
|
1106
|
+
if (abortController.signal.aborted) {
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
730
1109
|
activeConversationRuns += 1;
|
|
731
|
-
|
|
1110
|
+
emitLifecyclePhase('thinking');
|
|
732
1111
|
await messageHandler(sessionId, guildId, channelId, userId, username, combinedContent, attachmentContext.media, async (text, files) => {
|
|
733
|
-
|
|
734
|
-
await sendChunkedReply(msg, text, files, mentionLookup);
|
|
1112
|
+
emitLifecyclePhase('streaming');
|
|
1113
|
+
await sendChunkedReply(msg, text, files, mentionLookup, behavior.humanDelay);
|
|
735
1114
|
}, {
|
|
736
1115
|
sourceMessage: msg,
|
|
737
1116
|
batchedMessages: items.map((item) => item.msg),
|
|
738
1117
|
abortSignal: abortController.signal,
|
|
739
1118
|
stream,
|
|
740
1119
|
mentionLookup,
|
|
1120
|
+
emitLifecyclePhase,
|
|
741
1121
|
});
|
|
1122
|
+
emitLifecyclePhase('done');
|
|
1123
|
+
recordConversationMetric({
|
|
1124
|
+
durationMs: Date.now() - startedAt,
|
|
1125
|
+
ok: true,
|
|
1126
|
+
});
|
|
1127
|
+
noteConversationExchange(sourceItem.cooldownKey);
|
|
742
1128
|
}
|
|
743
1129
|
catch (error) {
|
|
1130
|
+
if (abortController.signal.aborted || inFlight.aborted) {
|
|
1131
|
+
logger.debug({ channelId, sessionId }, 'Conversation batch aborted before completion');
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
emitLifecyclePhase('error');
|
|
1135
|
+
recordConversationMetric({
|
|
1136
|
+
durationMs: Date.now() - startedAt,
|
|
1137
|
+
ok: false,
|
|
1138
|
+
error,
|
|
1139
|
+
});
|
|
744
1140
|
logger.error({ error, channelId, sessionId }, 'Conversation batch handling failed');
|
|
745
1141
|
const detail = error instanceof Error ? error.message : String(error);
|
|
746
1142
|
if (stream.hasSentMessages()) {
|
|
747
1143
|
await stream.fail(formatError('Gateway Error', detail));
|
|
748
1144
|
}
|
|
749
1145
|
else {
|
|
750
|
-
await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup);
|
|
1146
|
+
await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup, behavior.humanDelay);
|
|
751
1147
|
}
|
|
752
1148
|
}
|
|
753
1149
|
finally {
|
|
754
1150
|
activeConversationRuns = Math.max(0, activeConversationRuns - 1);
|
|
755
|
-
|
|
1151
|
+
if (releaseChannelSlot) {
|
|
1152
|
+
releaseChannelSlot();
|
|
1153
|
+
}
|
|
756
1154
|
for (const messageId of inFlight.messageIds) {
|
|
757
1155
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
758
1156
|
inFlightByMessageId.delete(messageId);
|
|
759
1157
|
}
|
|
760
1158
|
}
|
|
761
|
-
|
|
1159
|
+
typingController.stop();
|
|
762
1160
|
await Promise.all(items.map(async (item) => {
|
|
763
|
-
await item.
|
|
1161
|
+
await item.clearAckReaction();
|
|
764
1162
|
}));
|
|
765
1163
|
}
|
|
766
1164
|
};
|
|
767
|
-
const queueConversationMessage = async (msg, content) => {
|
|
1165
|
+
const queueConversationMessage = async (msg, content, behavior) => {
|
|
768
1166
|
const key = `${msg.channelId}:${msg.author.id}`;
|
|
769
|
-
const
|
|
770
|
-
const
|
|
1167
|
+
const cooldownKey = buildConversationCooldownKey(msg.channelId, msg.author.id);
|
|
1168
|
+
const adjustedHumanDelay = resolveHumanDelayWithBehavior(behavior.humanDelay, cooldownKey);
|
|
1169
|
+
const queuedBehavior = {
|
|
1170
|
+
...behavior,
|
|
1171
|
+
humanDelay: adjustedHumanDelay,
|
|
1172
|
+
};
|
|
1173
|
+
const wasExplicitlyAddressed = !msg.guild ||
|
|
1174
|
+
hasPrefixInvocation(msg.content || '') ||
|
|
1175
|
+
Boolean(client.user && msg.mentions.has(client.user));
|
|
1176
|
+
let clearAckReaction = async () => { };
|
|
1177
|
+
if (client.user && shouldApplyAckReaction(msg, behavior)) {
|
|
1178
|
+
const clearReaction = await addAckReaction({
|
|
1179
|
+
message: msg,
|
|
1180
|
+
emoji: behavior.ackReaction,
|
|
1181
|
+
withRetry: withDiscordRetry,
|
|
1182
|
+
botUserId: client.user.id,
|
|
1183
|
+
});
|
|
1184
|
+
clearAckReaction = behavior.removeAckAfterReply
|
|
1185
|
+
? clearReaction
|
|
1186
|
+
: async () => { };
|
|
1187
|
+
}
|
|
1188
|
+
const queued = {
|
|
1189
|
+
msg,
|
|
1190
|
+
content,
|
|
1191
|
+
behavior: queuedBehavior,
|
|
1192
|
+
clearAckReaction,
|
|
1193
|
+
wasExplicitlyAddressed,
|
|
1194
|
+
cooldownKey,
|
|
1195
|
+
};
|
|
771
1196
|
const existing = pendingBatches.get(key);
|
|
1197
|
+
const shouldDebounceMessage = shouldDebounceInbound({
|
|
1198
|
+
content: msg.content || '',
|
|
1199
|
+
hasAttachments: msg.attachments.size > 0,
|
|
1200
|
+
isPrefixedCommand: hasPrefixInvocation(msg.content || ''),
|
|
1201
|
+
});
|
|
772
1202
|
if (!existing) {
|
|
1203
|
+
const typingController = createTypingController(msg, behavior.typingMode);
|
|
1204
|
+
typingController.setPhase('received');
|
|
1205
|
+
const lifecycleController = client.user && DISCORD_LIFECYCLE_REACTIONS.enabled
|
|
1206
|
+
? new LifecycleReactionController({
|
|
1207
|
+
message: msg,
|
|
1208
|
+
withRetry: withDiscordRetry,
|
|
1209
|
+
botUserId: client.user.id,
|
|
1210
|
+
config: {
|
|
1211
|
+
enabled: DISCORD_LIFECYCLE_REACTIONS.enabled,
|
|
1212
|
+
removeOnComplete: DISCORD_LIFECYCLE_REACTIONS.removeOnComplete,
|
|
1213
|
+
phases: DISCORD_LIFECYCLE_REACTIONS.phases,
|
|
1214
|
+
},
|
|
1215
|
+
})
|
|
1216
|
+
: null;
|
|
1217
|
+
lifecycleController?.setPhase('queued');
|
|
1218
|
+
const baseDelayMs = shouldDebounceMessage ? behavior.debounceMs : 0;
|
|
1219
|
+
const startupStaggerMs = startupConnectedAtMs > 0 &&
|
|
1220
|
+
Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
|
|
1221
|
+
!wasExplicitlyAddressed
|
|
1222
|
+
? randomIntInRange(STARTUP_STAGGER_MIN_DELAY_MS, STARTUP_STAGGER_MAX_DELAY_MS)
|
|
1223
|
+
: 0;
|
|
1224
|
+
const delayMs = baseDelayMs + startupStaggerMs;
|
|
773
1225
|
const timer = setTimeout(() => {
|
|
774
1226
|
void dispatchConversationBatch(key);
|
|
775
|
-
},
|
|
1227
|
+
}, delayMs);
|
|
776
1228
|
pendingBatches.set(key, {
|
|
777
1229
|
items: [queued],
|
|
778
1230
|
timer,
|
|
1231
|
+
typingController,
|
|
1232
|
+
lifecycleController,
|
|
779
1233
|
});
|
|
780
1234
|
return;
|
|
781
1235
|
}
|
|
1236
|
+
existing.typingController.setPhase('received');
|
|
1237
|
+
existing.lifecycleController?.setPhase('queued');
|
|
782
1238
|
clearTimeout(existing.timer);
|
|
783
1239
|
existing.items.push(queued);
|
|
1240
|
+
const shouldFlushImmediately = !shouldDebounceMessage ||
|
|
1241
|
+
existing.items.length >= DEFAULT_DEBOUNCE_MAX_BUFFER;
|
|
1242
|
+
const baseDelayMs = shouldFlushImmediately ? 0 : behavior.debounceMs;
|
|
1243
|
+
const startupStaggerMs = startupConnectedAtMs > 0 &&
|
|
1244
|
+
Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
|
|
1245
|
+
!wasExplicitlyAddressed
|
|
1246
|
+
? randomIntInRange(STARTUP_STAGGER_MIN_DELAY_MS, STARTUP_STAGGER_MAX_DELAY_MS)
|
|
1247
|
+
: 0;
|
|
1248
|
+
const delayMs = baseDelayMs + startupStaggerMs;
|
|
784
1249
|
existing.timer = setTimeout(() => {
|
|
785
1250
|
void dispatchConversationBatch(key);
|
|
786
|
-
},
|
|
1251
|
+
}, delayMs);
|
|
787
1252
|
};
|
|
788
1253
|
const dropPendingMessage = async (messageId) => {
|
|
789
1254
|
for (const [key, pending] of pendingBatches) {
|
|
@@ -791,29 +1256,39 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
791
1256
|
if (index === -1)
|
|
792
1257
|
continue;
|
|
793
1258
|
const [removed] = pending.items.splice(index, 1);
|
|
794
|
-
await removed.
|
|
1259
|
+
await removed.clearAckReaction();
|
|
795
1260
|
if (pending.items.length === 0) {
|
|
796
1261
|
clearTimeout(pending.timer);
|
|
1262
|
+
pending.typingController.stop();
|
|
1263
|
+
await pending.lifecycleController?.clear();
|
|
797
1264
|
pendingBatches.delete(key);
|
|
798
1265
|
}
|
|
799
1266
|
return;
|
|
800
1267
|
}
|
|
801
1268
|
};
|
|
802
|
-
const updatePendingMessage = async (messageId, nextMsg, nextContent) => {
|
|
1269
|
+
const updatePendingMessage = async (messageId, nextMsg, nextContent, nextBehavior) => {
|
|
803
1270
|
for (const [key, pending] of pendingBatches) {
|
|
804
1271
|
const index = pending.items.findIndex((item) => item.msg.id === messageId);
|
|
805
1272
|
if (index === -1)
|
|
806
1273
|
continue;
|
|
807
1274
|
if (!nextContent) {
|
|
808
1275
|
const [removed] = pending.items.splice(index, 1);
|
|
809
|
-
await removed.
|
|
1276
|
+
await removed.clearAckReaction();
|
|
810
1277
|
}
|
|
811
1278
|
else {
|
|
812
1279
|
pending.items[index].msg = nextMsg;
|
|
813
1280
|
pending.items[index].content = nextContent;
|
|
1281
|
+
pending.items[index].behavior = nextBehavior;
|
|
1282
|
+
pending.items[index].wasExplicitlyAddressed =
|
|
1283
|
+
!nextMsg.guild ||
|
|
1284
|
+
hasPrefixInvocation(nextMsg.content || '') ||
|
|
1285
|
+
Boolean(client.user && nextMsg.mentions.has(client.user));
|
|
1286
|
+
pending.items[index].cooldownKey = buildConversationCooldownKey(nextMsg.channelId, nextMsg.author.id);
|
|
814
1287
|
}
|
|
815
1288
|
if (pending.items.length === 0) {
|
|
816
1289
|
clearTimeout(pending.timer);
|
|
1290
|
+
pending.typingController.stop();
|
|
1291
|
+
await pending.lifecycleController?.clear();
|
|
817
1292
|
pendingBatches.delete(key);
|
|
818
1293
|
}
|
|
819
1294
|
return true;
|
|
@@ -826,15 +1301,16 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
826
1301
|
const sessionId = getSessionId(msg);
|
|
827
1302
|
const guildId = msg.guild?.id || null;
|
|
828
1303
|
const channelId = msg.channelId;
|
|
1304
|
+
const behavior = resolveChannelBehavior(msg);
|
|
829
1305
|
const content = cleanIncomingContent(msg.content);
|
|
830
1306
|
observeMessageParticipants(msg, content);
|
|
831
1307
|
const immediateMentionLookup = buildMentionLookup([msg], [], msg.guild ? participantMemoryByChannel.get(msg.channelId) : undefined);
|
|
832
1308
|
const reply = async (text, files) => {
|
|
833
|
-
await sendChunkedReply(msg, text, files, immediateMentionLookup);
|
|
1309
|
+
await sendChunkedReply(msg, text, files, immediateMentionLookup, behavior.humanDelay);
|
|
834
1310
|
};
|
|
835
1311
|
const commandReply = async (text, files) => {
|
|
836
1312
|
try {
|
|
837
|
-
await sendChunkedDirectReply(msg, text, files, immediateMentionLookup);
|
|
1313
|
+
await sendChunkedDirectReply(msg, text, files, immediateMentionLookup, behavior.humanDelay);
|
|
838
1314
|
}
|
|
839
1315
|
catch (error) {
|
|
840
1316
|
logger.warn({ error, userId: msg.author.id, channelId: msg.channelId }, 'Failed to send command reply via DM; command response dropped');
|
|
@@ -867,7 +1343,7 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
867
1343
|
await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
|
|
868
1344
|
return;
|
|
869
1345
|
}
|
|
870
|
-
if (!isTrigger(msg))
|
|
1346
|
+
if (!isTrigger(msg, behavior))
|
|
871
1347
|
return;
|
|
872
1348
|
if (ignorePrefixCommand) {
|
|
873
1349
|
return;
|
|
@@ -885,7 +1361,14 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
885
1361
|
await reply('How can I help? Send me a message or try `!claw help`.');
|
|
886
1362
|
return;
|
|
887
1363
|
}
|
|
888
|
-
await
|
|
1364
|
+
const readWithoutReplyHandled = await maybeHandleReadWithoutReply(msg, content);
|
|
1365
|
+
if (readWithoutReplyHandled) {
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
const rateLimitAllowed = await enforcePerUserRateLimit(msg, behavior);
|
|
1369
|
+
if (!rateLimitAllowed)
|
|
1370
|
+
return;
|
|
1371
|
+
await queueConversationMessage(msg, content, behavior);
|
|
889
1372
|
});
|
|
890
1373
|
client.on('messageUpdate', async (_oldMsg, nextMsg) => {
|
|
891
1374
|
if (DISCORD_COMMANDS_ONLY)
|
|
@@ -898,15 +1381,19 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
898
1381
|
if (fetched.author?.bot)
|
|
899
1382
|
return;
|
|
900
1383
|
const updatedContent = cleanIncomingContent(fetched.content || '');
|
|
1384
|
+
const behavior = resolveChannelBehavior(fetched);
|
|
901
1385
|
observeMessageParticipants(fetched, updatedContent);
|
|
902
|
-
if (!isTrigger(fetched))
|
|
1386
|
+
if (!isTrigger(fetched, behavior)) {
|
|
1387
|
+
await updatePendingMessage(fetched.id, fetched, '', behavior);
|
|
903
1388
|
return;
|
|
904
|
-
|
|
1389
|
+
}
|
|
1390
|
+
await updatePendingMessage(fetched.id, fetched, updatedContent, behavior);
|
|
905
1391
|
const inFlight = inFlightByMessageId.get(fetched.id);
|
|
906
1392
|
if (!inFlight || inFlight.aborted)
|
|
907
1393
|
return;
|
|
908
1394
|
inFlight.aborted = true;
|
|
909
1395
|
inFlight.abortController.abort();
|
|
1396
|
+
inFlight.emitLifecyclePhase('error');
|
|
910
1397
|
for (const messageId of inFlight.messageIds) {
|
|
911
1398
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
912
1399
|
inFlightByMessageId.delete(messageId);
|
|
@@ -914,7 +1401,10 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
914
1401
|
}
|
|
915
1402
|
await inFlight.stream.discard();
|
|
916
1403
|
if (updatedContent) {
|
|
917
|
-
await
|
|
1404
|
+
const rateLimitAllowed = await enforcePerUserRateLimit(fetched, behavior);
|
|
1405
|
+
if (!rateLimitAllowed)
|
|
1406
|
+
return;
|
|
1407
|
+
await queueConversationMessage(fetched, updatedContent, behavior);
|
|
918
1408
|
}
|
|
919
1409
|
});
|
|
920
1410
|
client.on('messageDelete', async (msg) => {
|
|
@@ -924,6 +1414,7 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
924
1414
|
return;
|
|
925
1415
|
inFlight.aborted = true;
|
|
926
1416
|
inFlight.abortController.abort();
|
|
1417
|
+
inFlight.emitLifecyclePhase('error');
|
|
927
1418
|
for (const messageId of inFlight.messageIds) {
|
|
928
1419
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
929
1420
|
inFlightByMessageId.delete(messageId);
|
|
@@ -956,6 +1447,11 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
956
1447
|
client.login(DISCORD_TOKEN);
|
|
957
1448
|
return client;
|
|
958
1449
|
}
|
|
1450
|
+
export async function setDiscordMaintenancePresence() {
|
|
1451
|
+
if (!presenceController)
|
|
1452
|
+
return;
|
|
1453
|
+
await presenceController.setMaintenance();
|
|
1454
|
+
}
|
|
959
1455
|
/**
|
|
960
1456
|
* Send a message to a channel by ID (used by scheduler).
|
|
961
1457
|
*/
|