@hybridaione/hybridclaw 0.2.3 → 0.2.7
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 +95 -0
- package/CONTRIBUTING.md +33 -0
- package/README.md +57 -19
- package/SECURITY.md +17 -0
- package/biome.json +35 -0
- package/config.example.json +72 -7
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/approval-policy.ts +1405 -0
- package/container/src/browser-tools.ts +672 -136
- package/container/src/extensions.ts +36 -12
- package/container/src/hybridai-client.ts +65 -16
- package/container/src/index.ts +463 -110
- package/container/src/ipc.ts +5 -3
- package/container/src/token-usage.ts +107 -18
- package/container/src/tools.ts +600 -225
- package/container/src/types.ts +49 -2
- package/container/src/web-fetch.ts +89 -32
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +15 -4
- 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 +24 -4
- 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 +31 -0
- package/dist/channels/discord/inbound.d.ts.map +1 -1
- package/dist/channels/discord/inbound.js +156 -6
- 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 +7 -4
- package/dist/channels/discord/runtime.d.ts.map +1 -1
- package/dist/channels/discord/runtime.js +755 -150
- 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 +19 -12
- 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 +77 -23
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +24 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +113 -18
- package/dist/config.js.map +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +59 -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 +624 -87
- 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 +303 -26
- 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 +62 -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/hybridai-models.d.ts +8 -0
- package/dist/hybridai-models.d.ts.map +1 -0
- package/dist/hybridai-models.js +94 -0
- package/dist/hybridai-models.js.map +1 -0
- 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 +11 -7
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +104 -1
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +503 -24
- 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 +43 -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 +150 -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 +72 -13
- package/package.json +8 -2
- package/src/agent.ts +31 -4
- 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 +65 -14
- package/src/channels/discord/human-delay.ts +48 -0
- package/src/channels/discord/inbound.ts +190 -8
- 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 +1161 -205
- package/src/channels/discord/stream.ts +85 -31
- 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 +174 -57
- package/src/config.ts +208 -36
- package/src/container-runner.ts +134 -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 +996 -220
- package/src/gateway-types.ts +33 -0
- package/src/gateway.ts +487 -52
- package/src/health.ts +66 -26
- package/src/heartbeat.ts +99 -22
- package/src/hybridai-bots.ts +14 -5
- package/src/hybridai-models.ts +158 -0
- 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 +42 -17
- package/src/runtime-config.ts +1112 -122
- package/src/scheduled-task-runner.ts +77 -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 +199 -2
- package/src/update.ts +79 -23
- package/src/workspace.ts +63 -11
- package/tests/approval-policy.test.ts +335 -0
- package/tests/discord.basic.test.ts +213 -4
- package/tests/discord.human-presence.test.ts +85 -0
- package/tests/gateway-service.media-routing.test.ts +8 -2
- package/tests/hybridai-client.test.ts +112 -0
- package/tests/hybridai-models.test.ts +46 -0
- package/tests/memory-service.test.ts +1114 -0
- package/tests/token-efficiency.basic.test.ts +8 -2
- package/tests/token-usage.cache.test.ts +128 -0
- 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_ALLOWED_USER_IDS, DISCORD_COMMAND_MODE, 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';
|
|
4
|
-
import {
|
|
5
|
-
import { addMentionAlias, extractMentionAliasHints, normalizeMentionAlias, } from './mentions.js';
|
|
5
|
+
import { DEFAULT_DEBOUNCE_MAX_BUFFER, resolveInboundDebounceMs, shouldDebounceInbound, } from './debounce.js';
|
|
6
6
|
import { formatError, prepareChunkedPayloads, sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery, sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery, sendChunkedReply as sendChunkedReplyFromDelivery, } from './delivery.js';
|
|
7
|
-
import {
|
|
7
|
+
import { buildSessionIdFromContext as buildSessionIdFromContextInbound, cleanIncomingContent as cleanIncomingContentInbound, hasLooseBotMention as hasLooseBotMentionInbound, hasPrefixInvocation as hasPrefixInvocationInbound, hasSlashCommandInvocation as hasSlashCommandInvocationInbound, isAddressedToChannel as isAddressedToChannelInbound, isAuthorizedCommandUser as isAuthorizedCommandUserInbound, isTrigger as isTriggerInbound, parseCommand as parseCommandInbound, shouldReplyInFreeMode as shouldReplyInFreeModeInbound, shouldSkipFreeReplyBecauseOtherUsersMentioned as shouldSkipFreeReplyBecauseOtherUsersMentionedInbound, } from './inbound.js';
|
|
8
|
+
import { addMentionAlias, extractMentionAliasHints, normalizeMentionAlias, } from './mentions.js';
|
|
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
|
}
|
|
@@ -305,38 +343,150 @@ function getSessionId(msg) {
|
|
|
305
343
|
function hasPrefixInvocation(content) {
|
|
306
344
|
return hasPrefixInvocationInbound(content, botMentionRegex, DISCORD_PREFIX);
|
|
307
345
|
}
|
|
346
|
+
function hasSlashCommandInvocation(content) {
|
|
347
|
+
return hasSlashCommandInvocationInbound(content, botMentionRegex);
|
|
348
|
+
}
|
|
308
349
|
function isAuthorizedCommandUserId(userId) {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
350
|
+
return isAuthorizedCommandUserInbound({
|
|
351
|
+
mode: DISCORD_COMMAND_MODE,
|
|
352
|
+
userId,
|
|
353
|
+
allowedUserIds: DISCORD_COMMAND_ALLOWED_USER_IDS,
|
|
354
|
+
legacyCommandUserId: DISCORD_COMMAND_USER_ID,
|
|
355
|
+
});
|
|
313
356
|
}
|
|
314
357
|
function buildSessionIdFromContext(guildId, channelId, userId) {
|
|
315
358
|
return buildSessionIdFromContextInbound(guildId, channelId, userId);
|
|
316
359
|
}
|
|
317
|
-
function
|
|
360
|
+
function resolveGuildMessageMode(msg) {
|
|
361
|
+
if (!msg.guild)
|
|
362
|
+
return 'free';
|
|
363
|
+
if (DISCORD_GROUP_POLICY === 'disabled')
|
|
364
|
+
return 'off';
|
|
365
|
+
const guildConfig = DISCORD_GUILDS[msg.guild.id];
|
|
366
|
+
const explicitMode = guildConfig?.channels[msg.channelId]?.mode;
|
|
367
|
+
if (DISCORD_GROUP_POLICY === 'allowlist') {
|
|
368
|
+
return explicitMode ?? 'off';
|
|
369
|
+
}
|
|
370
|
+
if (explicitMode)
|
|
371
|
+
return explicitMode;
|
|
372
|
+
if (DISCORD_FREE_RESPONSE_CHANNELS.includes(msg.channelId))
|
|
373
|
+
return 'free';
|
|
374
|
+
if (guildConfig)
|
|
375
|
+
return guildConfig.defaultMode;
|
|
376
|
+
if (DISCORD_RESPOND_TO_ALL_MESSAGES)
|
|
377
|
+
return 'free';
|
|
378
|
+
return 'mention';
|
|
379
|
+
}
|
|
380
|
+
function resolveChannelBehavior(msg) {
|
|
381
|
+
const guildConfig = msg.guild ? DISCORD_GUILDS[msg.guild.id] : undefined;
|
|
382
|
+
const channelConfig = guildConfig?.channels[msg.channelId];
|
|
383
|
+
return {
|
|
384
|
+
guildMessageMode: resolveGuildMessageMode(msg),
|
|
385
|
+
typingMode: channelConfig?.typingMode ?? DISCORD_TYPING_MODE,
|
|
386
|
+
debounceMs: resolveInboundDebounceMs(DISCORD_DEBOUNCE_MS, channelConfig?.debounceMs),
|
|
387
|
+
ackReaction: (channelConfig?.ackReaction ?? DISCORD_ACK_REACTION).trim() ||
|
|
388
|
+
DISCORD_ACK_REACTION,
|
|
389
|
+
ackReactionScope: channelConfig?.ackReactionScope ?? DISCORD_ACK_REACTION_SCOPE,
|
|
390
|
+
removeAckAfterReply: channelConfig?.removeAckAfterReply ?? DISCORD_REMOVE_ACK_AFTER_REPLY,
|
|
391
|
+
humanDelay: channelConfig?.humanDelay ?? DISCORD_HUMAN_DELAY,
|
|
392
|
+
rateLimitPerUser: Math.max(0, channelConfig?.rateLimitPerUser ?? DISCORD_RATE_LIMIT_PER_USER),
|
|
393
|
+
suppressPatterns: channelConfig?.suppressPatterns ?? DISCORD_SUPPRESS_PATTERNS,
|
|
394
|
+
maxConcurrentPerChannel: Math.max(1, channelConfig?.maxConcurrentPerChannel ??
|
|
395
|
+
DISCORD_MAX_CONCURRENT_PER_CHANNEL),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function isTrigger(msg, behavior) {
|
|
318
399
|
return isTriggerInbound({
|
|
319
400
|
content: msg.content,
|
|
320
401
|
isDm: !msg.guild,
|
|
321
402
|
commandsOnly: DISCORD_COMMANDS_ONLY,
|
|
322
403
|
respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
|
|
404
|
+
guildMessageMode: behavior.guildMessageMode,
|
|
323
405
|
prefix: DISCORD_PREFIX,
|
|
324
406
|
botMentionRegex,
|
|
325
407
|
hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
|
|
408
|
+
suppressPatterns: behavior.suppressPatterns,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
function shouldHandleFreeModeMessage(msg, behavior, content) {
|
|
412
|
+
if (!msg.guild)
|
|
413
|
+
return true;
|
|
414
|
+
const hasPrefixedInvocation = hasPrefixInvocation(msg.content || '');
|
|
415
|
+
const hasBotMention = Boolean(client.user && msg.mentions.has(client.user));
|
|
416
|
+
const hasLooseBotMention = client.user != null
|
|
417
|
+
? hasLooseBotMentionInbound(content, [
|
|
418
|
+
client.user.username,
|
|
419
|
+
client.user.globalName || '',
|
|
420
|
+
msg.guild?.members.me?.displayName || '',
|
|
421
|
+
])
|
|
422
|
+
: false;
|
|
423
|
+
const isReplyToBot = Boolean(client.user && msg.mentions.repliedUser?.id === client.user.id);
|
|
424
|
+
const mentionedUserIds = Array.from(msg.mentions.users.keys());
|
|
425
|
+
const botUserId = client.user?.id ?? null;
|
|
426
|
+
if (shouldSkipFreeReplyBecauseOtherUsersMentionedInbound({
|
|
427
|
+
guildMessageMode: behavior.guildMessageMode,
|
|
428
|
+
hasBotMention,
|
|
429
|
+
hasPrefixInvocation: hasPrefixedInvocation,
|
|
430
|
+
botUserId,
|
|
431
|
+
mentionedUserIds,
|
|
432
|
+
})) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
return shouldReplyInFreeModeInbound({
|
|
436
|
+
guildMessageMode: behavior.guildMessageMode,
|
|
437
|
+
content,
|
|
438
|
+
hasBotMention,
|
|
439
|
+
hasLooseBotMention,
|
|
440
|
+
isAddressedToChannel: isAddressedToChannelInbound(content),
|
|
441
|
+
hasPrefixInvocation: hasPrefixedInvocation,
|
|
442
|
+
isReplyToBot,
|
|
443
|
+
hasAttachments: msg.attachments.size > 0,
|
|
326
444
|
});
|
|
327
445
|
}
|
|
446
|
+
function shouldApplyAckReaction(msg, behavior) {
|
|
447
|
+
const scope = behavior.ackReactionScope;
|
|
448
|
+
if (scope === 'off')
|
|
449
|
+
return false;
|
|
450
|
+
if (scope === 'all')
|
|
451
|
+
return true;
|
|
452
|
+
if (scope === 'direct')
|
|
453
|
+
return !msg.guild;
|
|
454
|
+
if (!msg.guild || !client.user)
|
|
455
|
+
return false;
|
|
456
|
+
return msg.mentions.has(client.user);
|
|
457
|
+
}
|
|
458
|
+
function isRateLimitExempt(msg) {
|
|
459
|
+
if (msg.author.id === DISCORD_COMMAND_USER_ID.trim())
|
|
460
|
+
return true;
|
|
461
|
+
if (!msg.guild)
|
|
462
|
+
return false;
|
|
463
|
+
if (!msg.member || DISCORD_RATE_LIMIT_EXEMPT_ROLES.length === 0)
|
|
464
|
+
return false;
|
|
465
|
+
const exemptByName = new Set(DISCORD_RATE_LIMIT_EXEMPT_ROLES.map((role) => role.trim().toLowerCase()).filter(Boolean));
|
|
466
|
+
if (exemptByName.size === 0)
|
|
467
|
+
return false;
|
|
468
|
+
for (const role of msg.member.roles.cache.values()) {
|
|
469
|
+
const normalized = role.name.trim().toLowerCase();
|
|
470
|
+
if (exemptByName.has(normalized))
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
328
475
|
function parseCommand(content) {
|
|
329
476
|
return parseCommandInbound(content, botMentionRegex, DISCORD_PREFIX);
|
|
330
477
|
}
|
|
331
478
|
function isRetryableDiscordError(error) {
|
|
332
479
|
const maybe = error;
|
|
333
480
|
const status = maybe.status ?? maybe.httpStatus;
|
|
334
|
-
return status === 429 ||
|
|
481
|
+
return (status === 429 ||
|
|
482
|
+
(typeof status === 'number' && status >= 500 && status <= 599));
|
|
335
483
|
}
|
|
336
484
|
function retryDelayMs(error, fallbackMs) {
|
|
337
485
|
const maybe = error;
|
|
338
486
|
const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
|
|
339
|
-
if (typeof retryAfterSeconds === 'number' &&
|
|
487
|
+
if (typeof retryAfterSeconds === 'number' &&
|
|
488
|
+
Number.isFinite(retryAfterSeconds) &&
|
|
489
|
+
retryAfterSeconds > 0) {
|
|
340
490
|
return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
|
|
341
491
|
}
|
|
342
492
|
return fallbackMs + Math.floor(Math.random() * 250);
|
|
@@ -350,7 +500,8 @@ async function withDiscordRetry(label, fn) {
|
|
|
350
500
|
return await fn();
|
|
351
501
|
}
|
|
352
502
|
catch (error) {
|
|
353
|
-
if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS ||
|
|
503
|
+
if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS ||
|
|
504
|
+
!isRetryableDiscordError(error)) {
|
|
354
505
|
throw error;
|
|
355
506
|
}
|
|
356
507
|
const waitMs = retryDelayMs(error, delayMs);
|
|
@@ -386,7 +537,9 @@ function buildChannelInfoContext(msg) {
|
|
|
386
537
|
if (channelTopic) {
|
|
387
538
|
lines.push(`- channel_topic: ${channelTopic}`);
|
|
388
539
|
}
|
|
389
|
-
const parentName = typeof namedChannel.parent?.name === 'string'
|
|
540
|
+
const parentName = typeof namedChannel.parent?.name === 'string'
|
|
541
|
+
? namedChannel.parent.name.trim()
|
|
542
|
+
: '';
|
|
390
543
|
if (parentName) {
|
|
391
544
|
lines.push(`- parent_channel: ${parentName}`);
|
|
392
545
|
}
|
|
@@ -395,7 +548,9 @@ function buildChannelInfoContext(msg) {
|
|
|
395
548
|
}
|
|
396
549
|
async function buildReplyContext(msg) {
|
|
397
550
|
const blocks = [];
|
|
398
|
-
if ('isThread' in msg.channel &&
|
|
551
|
+
if ('isThread' in msg.channel &&
|
|
552
|
+
typeof msg.channel.isThread === 'function' &&
|
|
553
|
+
msg.channel.isThread()) {
|
|
399
554
|
try {
|
|
400
555
|
const starter = await msg.channel.fetchStarterMessage();
|
|
401
556
|
if (starter) {
|
|
@@ -427,70 +582,22 @@ async function buildReplyContext(msg) {
|
|
|
427
582
|
return '';
|
|
428
583
|
return `${blocks.join('\n\n')}\n\n`;
|
|
429
584
|
}
|
|
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) {
|
|
585
|
+
async function sendChunkedReply(msg, text, files, mentionLookup, humanDelay) {
|
|
481
586
|
await sendChunkedReplyFromDelivery({
|
|
482
587
|
msg,
|
|
483
588
|
text,
|
|
484
589
|
withRetry: withDiscordRetry,
|
|
590
|
+
...(humanDelay ? { humanDelay } : {}),
|
|
485
591
|
...(files ? { files } : {}),
|
|
486
592
|
...(mentionLookup ? { mentionLookup } : {}),
|
|
487
593
|
});
|
|
488
594
|
}
|
|
489
|
-
async function sendChunkedDirectReply(msg, text, files, mentionLookup) {
|
|
595
|
+
async function sendChunkedDirectReply(msg, text, files, mentionLookup, humanDelay) {
|
|
490
596
|
await sendChunkedDirectReplyFromDelivery({
|
|
491
597
|
msg,
|
|
492
598
|
text,
|
|
493
599
|
withRetry: withDiscordRetry,
|
|
600
|
+
...(humanDelay ? { humanDelay } : {}),
|
|
494
601
|
...(files ? { files } : {}),
|
|
495
602
|
...(mentionLookup ? { mentionLookup } : {}),
|
|
496
603
|
});
|
|
@@ -503,52 +610,216 @@ async function sendChunkedInteractionReply(interaction, text, files) {
|
|
|
503
610
|
...(files ? { files } : {}),
|
|
504
611
|
});
|
|
505
612
|
}
|
|
506
|
-
async function
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
613
|
+
async function ensureSlashCommands() {
|
|
614
|
+
const definitions = [
|
|
615
|
+
{
|
|
616
|
+
name: 'status',
|
|
617
|
+
description: 'Show HybridClaw runtime status (only visible to you)',
|
|
618
|
+
dmPermission: true,
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
name: 'approve',
|
|
622
|
+
description: 'View/respond to pending tool approval requests (private)',
|
|
623
|
+
dmPermission: true,
|
|
624
|
+
options: [
|
|
625
|
+
{
|
|
626
|
+
type: ApplicationCommandOptionType.String,
|
|
627
|
+
name: 'action',
|
|
628
|
+
description: 'Action to perform',
|
|
629
|
+
required: false,
|
|
630
|
+
choices: [
|
|
631
|
+
{ name: 'view', value: 'view' },
|
|
632
|
+
{ name: 'yes', value: 'yes' },
|
|
633
|
+
{ name: 'session', value: 'session' },
|
|
634
|
+
{ name: 'agent', value: 'agent' },
|
|
635
|
+
{ name: 'no', value: 'no' },
|
|
636
|
+
],
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
type: ApplicationCommandOptionType.String,
|
|
640
|
+
name: 'approval_id',
|
|
641
|
+
description: 'Optional approval id (defaults to latest pending)',
|
|
642
|
+
required: false,
|
|
643
|
+
},
|
|
644
|
+
],
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
name: 'channel-mode',
|
|
648
|
+
description: 'Set this channel to off, mention-only, or free-response',
|
|
649
|
+
options: [
|
|
650
|
+
{
|
|
651
|
+
type: ApplicationCommandOptionType.String,
|
|
652
|
+
name: 'mode',
|
|
653
|
+
description: 'Response mode for this channel',
|
|
654
|
+
required: true,
|
|
655
|
+
choices: [
|
|
656
|
+
{ name: 'off', value: 'off' },
|
|
657
|
+
{ name: 'mention', value: 'mention' },
|
|
658
|
+
{ name: 'free', value: 'free' },
|
|
659
|
+
],
|
|
660
|
+
},
|
|
661
|
+
],
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
name: 'channel-policy',
|
|
665
|
+
description: 'Set guild channel policy to open, allowlist, or disabled',
|
|
666
|
+
options: [
|
|
667
|
+
{
|
|
668
|
+
type: ApplicationCommandOptionType.String,
|
|
669
|
+
name: 'policy',
|
|
670
|
+
description: 'Guild channel policy',
|
|
671
|
+
required: true,
|
|
672
|
+
choices: [
|
|
673
|
+
{ name: 'open', value: 'open' },
|
|
674
|
+
{ name: 'allowlist', value: 'allowlist' },
|
|
675
|
+
{ name: 'disabled', value: 'disabled' },
|
|
676
|
+
],
|
|
677
|
+
},
|
|
678
|
+
],
|
|
679
|
+
},
|
|
680
|
+
];
|
|
511
681
|
if (!client.application)
|
|
512
682
|
return;
|
|
683
|
+
const globalDefinitions = definitions.filter((definition) => definition.name === 'status' || definition.name === 'approve');
|
|
684
|
+
try {
|
|
685
|
+
for (const definition of globalDefinitions) {
|
|
686
|
+
// POST is an upsert by name for global commands. Keep command IDs stable
|
|
687
|
+
// to avoid stale-client command references in DMs.
|
|
688
|
+
await client.application.commands.create(definition);
|
|
689
|
+
logger.info({ scope: 'global', command: definition.name }, 'Upserted slash command');
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
logger.warn({ error }, 'Failed to register global slash commands');
|
|
694
|
+
}
|
|
513
695
|
await Promise.allSettled([...client.guilds.cache.values()].map(async (guild) => {
|
|
514
696
|
try {
|
|
515
697
|
const existing = await guild.commands.fetch();
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
698
|
+
for (const definition of definitions) {
|
|
699
|
+
const current = existing.find((command) => command.name === definition.name);
|
|
700
|
+
if (!current) {
|
|
701
|
+
await guild.commands.create(definition);
|
|
702
|
+
logger.info({ guildId: guild.id, command: definition.name }, 'Registered slash command');
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
523
705
|
await guild.commands.edit(current.id, definition);
|
|
524
|
-
logger.info({ guildId: guild.id }, 'Updated slash command
|
|
706
|
+
logger.info({ guildId: guild.id, command: definition.name }, 'Updated slash command');
|
|
525
707
|
}
|
|
526
708
|
}
|
|
527
709
|
catch (error) {
|
|
528
|
-
logger.warn({ error, guildId: guild.id }, 'Failed to register slash
|
|
710
|
+
logger.warn({ error, guildId: guild.id }, 'Failed to register Discord slash commands');
|
|
529
711
|
}
|
|
530
712
|
}));
|
|
531
713
|
}
|
|
532
|
-
function
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
714
|
+
function trimRecentConversationMetrics(nowMs = Date.now()) {
|
|
715
|
+
const cutoff = nowMs - PRESENCE_WINDOW_MS;
|
|
716
|
+
while (recentConversationMetrics.length > 0 &&
|
|
717
|
+
recentConversationMetrics[0].atMs < cutoff) {
|
|
718
|
+
recentConversationMetrics.shift();
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
function recordConversationMetric(params) {
|
|
722
|
+
const nowMs = Date.now();
|
|
723
|
+
const errorText = params.error instanceof Error
|
|
724
|
+
? params.error.message
|
|
725
|
+
: String(params.error || '');
|
|
726
|
+
const exhaustedHint = !params.ok && PRESENCE_EXHAUSTED_ERROR_RE.test(errorText);
|
|
727
|
+
if (params.ok) {
|
|
728
|
+
consecutiveConversationFailures = 0;
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
consecutiveConversationFailures += 1;
|
|
732
|
+
}
|
|
733
|
+
recentConversationMetrics.push({
|
|
734
|
+
atMs: nowMs,
|
|
735
|
+
durationMs: Math.max(0, Math.floor(params.durationMs)),
|
|
736
|
+
ok: params.ok,
|
|
737
|
+
exhaustedHint,
|
|
738
|
+
});
|
|
739
|
+
trimRecentConversationMetrics(nowMs);
|
|
740
|
+
}
|
|
741
|
+
function resolvePresenceHealthState() {
|
|
742
|
+
trimRecentConversationMetrics();
|
|
743
|
+
if (activeConversationRuns >= 4)
|
|
744
|
+
return 'degraded';
|
|
745
|
+
if (consecutiveConversationFailures >= 3)
|
|
746
|
+
return 'exhausted';
|
|
747
|
+
if (recentConversationMetrics.some((entry) => entry.exhaustedHint))
|
|
748
|
+
return 'exhausted';
|
|
749
|
+
if (recentConversationMetrics.length === 0)
|
|
750
|
+
return 'healthy';
|
|
751
|
+
const totalDuration = recentConversationMetrics.reduce((sum, entry) => sum + entry.durationMs, 0);
|
|
752
|
+
const avgDuration = totalDuration / recentConversationMetrics.length;
|
|
753
|
+
const failureCount = recentConversationMetrics.filter((entry) => !entry.ok).length;
|
|
754
|
+
const errorRate = failureCount / recentConversationMetrics.length;
|
|
755
|
+
if (errorRate >= 0.25 || avgDuration >= PRESENCE_DEGRADED_DURATION_MS)
|
|
756
|
+
return 'degraded';
|
|
757
|
+
return 'healthy';
|
|
758
|
+
}
|
|
759
|
+
function randomIntInRange(minMs, maxMs) {
|
|
760
|
+
const lo = Math.floor(Math.max(0, minMs));
|
|
761
|
+
const hi = Math.floor(Math.max(lo, maxMs));
|
|
762
|
+
if (hi <= lo)
|
|
763
|
+
return lo;
|
|
764
|
+
return lo + Math.floor(Math.random() * (hi - lo + 1));
|
|
765
|
+
}
|
|
766
|
+
function isNightOrWeekend(now) {
|
|
767
|
+
const day = now.getDay();
|
|
768
|
+
const hour = now.getHours();
|
|
769
|
+
const weekend = day === 0 || day === 6;
|
|
770
|
+
const night = hour >= NIGHT_HOURS_START || hour < NIGHT_HOURS_END;
|
|
771
|
+
return weekend || night;
|
|
772
|
+
}
|
|
773
|
+
function buildConversationCooldownKey(channelId, userId) {
|
|
774
|
+
return `${channelId}:${userId}`;
|
|
775
|
+
}
|
|
776
|
+
function resolveHumanDelayWithBehavior(base, cooldownKey) {
|
|
777
|
+
if (base.mode === 'off')
|
|
778
|
+
return base;
|
|
779
|
+
const now = new Date();
|
|
780
|
+
let factor = isNightOrWeekend(now) ? 1.5 : 1;
|
|
781
|
+
const record = conversationExchangeByKey.get(cooldownKey);
|
|
782
|
+
if (record) {
|
|
783
|
+
const elapsedMs = Date.now() - record.lastAtMs;
|
|
784
|
+
if (elapsedMs > CONVERSATION_COOLDOWN_RESET_MS) {
|
|
785
|
+
conversationExchangeByKey.delete(cooldownKey);
|
|
786
|
+
}
|
|
787
|
+
else if (record.count > CONVERSATION_COOLDOWN_THRESHOLD) {
|
|
788
|
+
const extra = Math.min(CONVERSATION_COOLDOWN_MAX_FACTOR - 1, (record.count - CONVERSATION_COOLDOWN_THRESHOLD) * 0.12);
|
|
789
|
+
factor += Math.max(0, extra);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (factor <= 1)
|
|
793
|
+
return base;
|
|
794
|
+
const minMs = Math.round((base.minMs ?? 800) * factor);
|
|
795
|
+
const maxMs = Math.round((base.maxMs ?? 2_500) * factor);
|
|
796
|
+
return {
|
|
797
|
+
mode: 'custom',
|
|
798
|
+
minMs,
|
|
799
|
+
maxMs: Math.max(minMs, maxMs),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
function noteConversationExchange(cooldownKey) {
|
|
803
|
+
const nowMs = Date.now();
|
|
804
|
+
const existing = conversationExchangeByKey.get(cooldownKey);
|
|
805
|
+
if (!existing || nowMs - existing.lastAtMs > CONVERSATION_COOLDOWN_RESET_MS) {
|
|
806
|
+
conversationExchangeByKey.set(cooldownKey, { count: 1, lastAtMs: nowMs });
|
|
540
807
|
return;
|
|
541
808
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
809
|
+
conversationExchangeByKey.set(cooldownKey, {
|
|
810
|
+
count: existing.count + 1,
|
|
811
|
+
lastAtMs: nowMs,
|
|
545
812
|
});
|
|
546
813
|
}
|
|
814
|
+
function pickReadWithoutReplyEmoji() {
|
|
815
|
+
return Math.random() < 0.7 ? '👍' : '✅';
|
|
816
|
+
}
|
|
547
817
|
export function initDiscord(onMessage, onCommand) {
|
|
548
818
|
messageHandler = onMessage;
|
|
549
819
|
commandHandler = onCommand;
|
|
550
820
|
const pendingBatches = new Map();
|
|
551
821
|
const inFlightByMessageId = new Map();
|
|
822
|
+
const channelConcurrencyById = new Map();
|
|
552
823
|
const negativeFeedbackByChannel = new Map();
|
|
553
824
|
const participantMemoryByChannel = new Map();
|
|
554
825
|
const touchParticipantMemoryChannel = (channelId) => {
|
|
@@ -623,6 +894,103 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
623
894
|
rememberParticipantAliasForChannel(msg.channelId, hint.userId, hint.alias);
|
|
624
895
|
}
|
|
625
896
|
};
|
|
897
|
+
const waitForChannelConcurrencySlot = async (channelId, maxConcurrent, abortSignal) => {
|
|
898
|
+
const boundedMax = Math.max(1, Math.floor(maxConcurrent));
|
|
899
|
+
while ((channelConcurrencyById.get(channelId) ?? 0) >= boundedMax) {
|
|
900
|
+
if (abortSignal?.aborted) {
|
|
901
|
+
throw new Error('Conversation aborted while waiting for channel concurrency slot.');
|
|
902
|
+
}
|
|
903
|
+
await new Promise((resolve) => setTimeout(resolve, CONCURRENCY_RETRY_DELAY_MS));
|
|
904
|
+
}
|
|
905
|
+
if (abortSignal?.aborted) {
|
|
906
|
+
throw new Error('Conversation aborted before acquiring channel concurrency slot.');
|
|
907
|
+
}
|
|
908
|
+
channelConcurrencyById.set(channelId, (channelConcurrencyById.get(channelId) ?? 0) + 1);
|
|
909
|
+
return () => {
|
|
910
|
+
const current = channelConcurrencyById.get(channelId) ?? 0;
|
|
911
|
+
const next = Math.max(0, current - 1);
|
|
912
|
+
if (next === 0) {
|
|
913
|
+
channelConcurrencyById.delete(channelId);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
channelConcurrencyById.set(channelId, next);
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
};
|
|
920
|
+
const enforcePerUserRateLimit = async (msg, behavior) => {
|
|
921
|
+
const limit = Math.max(0, Math.floor(behavior.rateLimitPerUser));
|
|
922
|
+
if (limit === 0)
|
|
923
|
+
return true;
|
|
924
|
+
if (isRateLimitExempt(msg))
|
|
925
|
+
return true;
|
|
926
|
+
const key = `${msg.channelId}:${msg.author.id}`;
|
|
927
|
+
const decision = userRateLimiter.check(key, limit);
|
|
928
|
+
if (decision.allowed)
|
|
929
|
+
return true;
|
|
930
|
+
if (userRateLimiter.shouldNotify(key, RATE_LIMIT_NOTIFY_COOLDOWN_MS)) {
|
|
931
|
+
try {
|
|
932
|
+
await withDiscordRetry('rate-limit-reply', () => msg.reply({ content: FRIENDLY_RATE_LIMIT_MESSAGE }));
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
logger.debug({ error, channelId: msg.channelId, userId: msg.author.id }, 'Failed to send rate-limit warning');
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return false;
|
|
939
|
+
};
|
|
940
|
+
const maybeHandleReadWithoutReply = async (msg, content) => {
|
|
941
|
+
if (!content.trim())
|
|
942
|
+
return false;
|
|
943
|
+
if (!msg.guild)
|
|
944
|
+
return false;
|
|
945
|
+
if (msg.attachments.size > 0)
|
|
946
|
+
return false;
|
|
947
|
+
if (hasPrefixInvocation(msg.content || ''))
|
|
948
|
+
return false;
|
|
949
|
+
if (hasSlashCommandInvocation(msg.content || ''))
|
|
950
|
+
return false;
|
|
951
|
+
if (client.user && msg.mentions.has(client.user))
|
|
952
|
+
return false;
|
|
953
|
+
if (content.trim().length > 80)
|
|
954
|
+
return false;
|
|
955
|
+
if (!READ_WITHOUT_REPLY_RE.test(content.trim()))
|
|
956
|
+
return false;
|
|
957
|
+
if (Math.random() > READ_WITHOUT_REPLY_PROBABILITY)
|
|
958
|
+
return false;
|
|
959
|
+
try {
|
|
960
|
+
await withDiscordRetry('reaction-read-without-reply', () => msg.react(pickReadWithoutReplyEmoji()));
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
963
|
+
catch (error) {
|
|
964
|
+
logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed read-without-reply reaction');
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
const shouldSelectivelySilence = (params) => {
|
|
969
|
+
if (!params.sourceItem.msg.guild)
|
|
970
|
+
return false;
|
|
971
|
+
if (params.behavior.guildMessageMode !== 'free')
|
|
972
|
+
return false;
|
|
973
|
+
if (params.sourceItem.wasExplicitlyAddressed)
|
|
974
|
+
return false;
|
|
975
|
+
const nowMs = Date.now();
|
|
976
|
+
const peerMessages = params.inboundHistory
|
|
977
|
+
.filter((entry) => !entry.isBot &&
|
|
978
|
+
entry.userId !== params.sourceItem.msg.author.id &&
|
|
979
|
+
nowMs - entry.timestampMs <= SELECTIVE_SILENCE_RECENT_WINDOW_MS)
|
|
980
|
+
.sort((a, b) => a.timestampMs - b.timestampMs);
|
|
981
|
+
if (peerMessages.length === 0)
|
|
982
|
+
return false;
|
|
983
|
+
const sourceText = params.sourceItem.content.trim();
|
|
984
|
+
const asksQuestion = sourceText.includes('?');
|
|
985
|
+
const latestPeer = peerMessages[peerMessages.length - 1]?.content?.trim().toLowerCase() ||
|
|
986
|
+
'';
|
|
987
|
+
const peerLooksLikeAnswer = latestPeer.length >= 24 ||
|
|
988
|
+
/\\b(you can|try|use|it is|it's|because|should|here|answer|fix)\\b/.test(latestPeer);
|
|
989
|
+
const probability = asksQuestion && peerLooksLikeAnswer
|
|
990
|
+
? SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY
|
|
991
|
+
: SELECTIVE_SILENCE_BASE_PROBABILITY;
|
|
992
|
+
return Math.random() < probability;
|
|
993
|
+
};
|
|
626
994
|
const intents = [
|
|
627
995
|
GatewayIntentBits.Guilds,
|
|
628
996
|
GatewayIntentBits.GuildMessages,
|
|
@@ -636,7 +1004,12 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
636
1004
|
intents.push(GatewayIntentBits.GuildPresences);
|
|
637
1005
|
client = new Client({
|
|
638
1006
|
intents,
|
|
639
|
-
partials: [
|
|
1007
|
+
partials: [
|
|
1008
|
+
Partials.Channel,
|
|
1009
|
+
Partials.Message,
|
|
1010
|
+
Partials.Reaction,
|
|
1011
|
+
Partials.User,
|
|
1012
|
+
],
|
|
640
1013
|
});
|
|
641
1014
|
client.on('presenceUpdate', (_oldPresence, nextPresence) => {
|
|
642
1015
|
const userId = nextPresence.userId || nextPresence.user?.id;
|
|
@@ -654,30 +1027,91 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
654
1027
|
});
|
|
655
1028
|
client.on('clientReady', () => {
|
|
656
1029
|
logger.info({ user: client.user?.tag }, 'Discord bot connected');
|
|
1030
|
+
startupConnectedAtMs = Date.now();
|
|
657
1031
|
if (client.user) {
|
|
658
1032
|
botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
|
|
659
1033
|
}
|
|
660
|
-
|
|
661
|
-
|
|
1034
|
+
presenceController?.stop();
|
|
1035
|
+
presenceController = new DiscordAutoPresenceController({
|
|
1036
|
+
client,
|
|
1037
|
+
getConfig: () => DISCORD_SELF_PRESENCE,
|
|
1038
|
+
resolveState: resolvePresenceHealthState,
|
|
1039
|
+
});
|
|
1040
|
+
presenceController.start();
|
|
1041
|
+
void ensureSlashCommands();
|
|
662
1042
|
});
|
|
663
1043
|
client.on('interactionCreate', async (interaction) => {
|
|
664
1044
|
if (!interaction.isChatInputCommand())
|
|
665
1045
|
return;
|
|
666
|
-
if (interaction.commandName !== 'status'
|
|
1046
|
+
if (interaction.commandName !== 'status' &&
|
|
1047
|
+
interaction.commandName !== 'approve' &&
|
|
1048
|
+
interaction.commandName !== 'channel-mode' &&
|
|
1049
|
+
interaction.commandName !== 'channel-policy') {
|
|
667
1050
|
return;
|
|
668
|
-
|
|
1051
|
+
}
|
|
1052
|
+
if (interaction.guildId &&
|
|
1053
|
+
!isAuthorizedCommandUserId(interaction.user.id)) {
|
|
669
1054
|
await sendChunkedInteractionReply(interaction, 'You are not authorized to run commands for this bot.');
|
|
670
1055
|
return;
|
|
671
1056
|
}
|
|
672
1057
|
const guildId = interaction.guildId ?? null;
|
|
673
1058
|
const channelId = interaction.channelId;
|
|
674
1059
|
const sessionId = buildSessionIdFromContext(guildId, channelId, interaction.user.id);
|
|
1060
|
+
const args = (() => {
|
|
1061
|
+
if (interaction.commandName === 'status')
|
|
1062
|
+
return ['status'];
|
|
1063
|
+
if (interaction.commandName === 'approve') {
|
|
1064
|
+
const action = interaction.options
|
|
1065
|
+
.getString('action', false)
|
|
1066
|
+
?.trim()
|
|
1067
|
+
.toLowerCase() || 'view';
|
|
1068
|
+
if (action !== 'view' &&
|
|
1069
|
+
action !== 'yes' &&
|
|
1070
|
+
action !== 'session' &&
|
|
1071
|
+
action !== 'agent' &&
|
|
1072
|
+
action !== 'no') {
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
const approvalId = interaction.options.getString('approval_id', false)?.trim() || '';
|
|
1076
|
+
return approvalId
|
|
1077
|
+
? ['approve', action, approvalId]
|
|
1078
|
+
: ['approve', action];
|
|
1079
|
+
}
|
|
1080
|
+
if (interaction.commandName === 'channel-mode') {
|
|
1081
|
+
if (!interaction.guildId)
|
|
1082
|
+
return null;
|
|
1083
|
+
const selectedMode = interaction.options
|
|
1084
|
+
.getString('mode', true)
|
|
1085
|
+
.trim()
|
|
1086
|
+
.toLowerCase();
|
|
1087
|
+
if (selectedMode !== 'off' &&
|
|
1088
|
+
selectedMode !== 'mention' &&
|
|
1089
|
+
selectedMode !== 'free')
|
|
1090
|
+
return null;
|
|
1091
|
+
return ['channel', 'mode', selectedMode];
|
|
1092
|
+
}
|
|
1093
|
+
if (!interaction.guildId)
|
|
1094
|
+
return null;
|
|
1095
|
+
const selectedPolicy = interaction.options
|
|
1096
|
+
.getString('policy', true)
|
|
1097
|
+
.trim()
|
|
1098
|
+
.toLowerCase();
|
|
1099
|
+
if (selectedPolicy !== 'open' &&
|
|
1100
|
+
selectedPolicy !== 'allowlist' &&
|
|
1101
|
+
selectedPolicy !== 'disabled')
|
|
1102
|
+
return null;
|
|
1103
|
+
return ['channel', 'policy', selectedPolicy];
|
|
1104
|
+
})();
|
|
1105
|
+
if (!args) {
|
|
1106
|
+
await sendChunkedInteractionReply(interaction, 'This command can only be used in a server channel with a valid mode/policy option.');
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
675
1109
|
try {
|
|
676
|
-
await commandHandler(sessionId, guildId, channelId,
|
|
1110
|
+
await commandHandler(sessionId, guildId, channelId, interaction.user.id, interaction.user.username, args, async (text, files) => sendChunkedInteractionReply(interaction, text, files));
|
|
677
1111
|
}
|
|
678
1112
|
catch (error) {
|
|
679
1113
|
const detail = error instanceof Error ? error.message : String(error);
|
|
680
|
-
logger.error({ error, guildId, channelId, userId: interaction.user.id }, 'Discord slash
|
|
1114
|
+
logger.error({ error, guildId, channelId, userId: interaction.user.id }, 'Discord slash command failed');
|
|
681
1115
|
await sendChunkedInteractionReply(interaction, formatError('Gateway Error', detail));
|
|
682
1116
|
}
|
|
683
1117
|
});
|
|
@@ -696,8 +1130,13 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
696
1130
|
const channelId = msg.channelId;
|
|
697
1131
|
const userId = msg.author.id;
|
|
698
1132
|
const username = msg.author.username;
|
|
1133
|
+
const behavior = sourceItem.behavior;
|
|
1134
|
+
const startedAt = Date.now();
|
|
1135
|
+
let releaseChannelSlot = null;
|
|
699
1136
|
const batchedContent = items.length > 1
|
|
700
|
-
? items
|
|
1137
|
+
? items
|
|
1138
|
+
.map((item, index) => `Message ${index + 1}:\n${item.content}`)
|
|
1139
|
+
.join('\n\n')
|
|
701
1140
|
: sourceItem.content;
|
|
702
1141
|
const channelInfoContext = buildChannelInfoContext(msg);
|
|
703
1142
|
const replyContext = await buildReplyContext(msg);
|
|
@@ -712,78 +1151,206 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
712
1151
|
const participantContext = buildParticipantContext(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
|
|
713
1152
|
const mentionLookup = buildMentionLookup(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
|
|
714
1153
|
const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext.context}${participantContext}${batchedContent}`;
|
|
1154
|
+
const selectiveSilence = shouldSelectivelySilence({
|
|
1155
|
+
sourceItem,
|
|
1156
|
+
inboundHistory: inboundHistory.entries,
|
|
1157
|
+
behavior,
|
|
1158
|
+
});
|
|
715
1159
|
const abortController = new AbortController();
|
|
716
|
-
const
|
|
1160
|
+
const typingController = pending.typingController;
|
|
1161
|
+
const lifecycleController = pending.lifecycleController;
|
|
1162
|
+
const emitLifecyclePhase = (phase) => {
|
|
1163
|
+
if (phase === 'queued') {
|
|
1164
|
+
typingController.setPhase('received');
|
|
1165
|
+
}
|
|
1166
|
+
else if (phase === 'thinking') {
|
|
1167
|
+
typingController.setPhase('thinking');
|
|
1168
|
+
}
|
|
1169
|
+
else if (phase === 'toolUse') {
|
|
1170
|
+
typingController.setPhase('toolUse');
|
|
1171
|
+
}
|
|
1172
|
+
else if (phase === 'streaming') {
|
|
1173
|
+
typingController.setPhase('streaming');
|
|
1174
|
+
}
|
|
1175
|
+
else {
|
|
1176
|
+
typingController.setPhase('done');
|
|
1177
|
+
}
|
|
1178
|
+
lifecycleController?.setPhase(phase);
|
|
1179
|
+
};
|
|
717
1180
|
const stream = new DiscordStreamManager(msg, {
|
|
718
|
-
onFirstMessage: () =>
|
|
1181
|
+
onFirstMessage: () => emitLifecyclePhase('streaming'),
|
|
1182
|
+
humanDelay: behavior.humanDelay,
|
|
719
1183
|
});
|
|
720
1184
|
const inFlight = {
|
|
721
1185
|
abortController,
|
|
722
1186
|
stream,
|
|
723
1187
|
messageIds: new Set(items.map((item) => item.msg.id)),
|
|
724
1188
|
aborted: false,
|
|
1189
|
+
emitLifecyclePhase,
|
|
725
1190
|
};
|
|
726
1191
|
for (const messageId of inFlight.messageIds) {
|
|
727
1192
|
inFlightByMessageId.set(messageId, inFlight);
|
|
728
1193
|
}
|
|
729
1194
|
try {
|
|
1195
|
+
if (selectiveSilence) {
|
|
1196
|
+
emitLifecyclePhase('done');
|
|
1197
|
+
if (Math.random() < 0.5) {
|
|
1198
|
+
await withDiscordRetry('reaction-selective-silence', () => msg.react(pickReadWithoutReplyEmoji())).catch(() => { });
|
|
1199
|
+
}
|
|
1200
|
+
recordConversationMetric({
|
|
1201
|
+
durationMs: Date.now() - startedAt,
|
|
1202
|
+
ok: true,
|
|
1203
|
+
});
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
releaseChannelSlot = await waitForChannelConcurrencySlot(channelId, behavior.maxConcurrentPerChannel, abortController.signal);
|
|
1207
|
+
if (abortController.signal.aborted) {
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
730
1210
|
activeConversationRuns += 1;
|
|
731
|
-
|
|
1211
|
+
emitLifecyclePhase('thinking');
|
|
732
1212
|
await messageHandler(sessionId, guildId, channelId, userId, username, combinedContent, attachmentContext.media, async (text, files) => {
|
|
733
|
-
|
|
734
|
-
await sendChunkedReply(msg, text, files, mentionLookup);
|
|
1213
|
+
emitLifecyclePhase('streaming');
|
|
1214
|
+
await sendChunkedReply(msg, text, files, mentionLookup, behavior.humanDelay);
|
|
735
1215
|
}, {
|
|
736
1216
|
sourceMessage: msg,
|
|
737
1217
|
batchedMessages: items.map((item) => item.msg),
|
|
738
1218
|
abortSignal: abortController.signal,
|
|
739
1219
|
stream,
|
|
740
1220
|
mentionLookup,
|
|
1221
|
+
emitLifecyclePhase,
|
|
1222
|
+
});
|
|
1223
|
+
emitLifecyclePhase('done');
|
|
1224
|
+
recordConversationMetric({
|
|
1225
|
+
durationMs: Date.now() - startedAt,
|
|
1226
|
+
ok: true,
|
|
741
1227
|
});
|
|
1228
|
+
noteConversationExchange(sourceItem.cooldownKey);
|
|
742
1229
|
}
|
|
743
1230
|
catch (error) {
|
|
1231
|
+
if (abortController.signal.aborted || inFlight.aborted) {
|
|
1232
|
+
logger.debug({ channelId, sessionId }, 'Conversation batch aborted before completion');
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
emitLifecyclePhase('error');
|
|
1236
|
+
recordConversationMetric({
|
|
1237
|
+
durationMs: Date.now() - startedAt,
|
|
1238
|
+
ok: false,
|
|
1239
|
+
error,
|
|
1240
|
+
});
|
|
744
1241
|
logger.error({ error, channelId, sessionId }, 'Conversation batch handling failed');
|
|
745
1242
|
const detail = error instanceof Error ? error.message : String(error);
|
|
746
1243
|
if (stream.hasSentMessages()) {
|
|
747
1244
|
await stream.fail(formatError('Gateway Error', detail));
|
|
748
1245
|
}
|
|
749
1246
|
else {
|
|
750
|
-
await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup);
|
|
1247
|
+
await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup, behavior.humanDelay);
|
|
751
1248
|
}
|
|
752
1249
|
}
|
|
753
1250
|
finally {
|
|
754
1251
|
activeConversationRuns = Math.max(0, activeConversationRuns - 1);
|
|
755
|
-
|
|
1252
|
+
if (releaseChannelSlot) {
|
|
1253
|
+
releaseChannelSlot();
|
|
1254
|
+
}
|
|
756
1255
|
for (const messageId of inFlight.messageIds) {
|
|
757
1256
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
758
1257
|
inFlightByMessageId.delete(messageId);
|
|
759
1258
|
}
|
|
760
1259
|
}
|
|
761
|
-
|
|
1260
|
+
typingController.stop();
|
|
762
1261
|
await Promise.all(items.map(async (item) => {
|
|
763
|
-
await item.
|
|
1262
|
+
await item.clearAckReaction();
|
|
764
1263
|
}));
|
|
765
1264
|
}
|
|
766
1265
|
};
|
|
767
|
-
const queueConversationMessage = async (msg, content) => {
|
|
1266
|
+
const queueConversationMessage = async (msg, content, behavior) => {
|
|
768
1267
|
const key = `${msg.channelId}:${msg.author.id}`;
|
|
769
|
-
const
|
|
770
|
-
const
|
|
1268
|
+
const cooldownKey = buildConversationCooldownKey(msg.channelId, msg.author.id);
|
|
1269
|
+
const adjustedHumanDelay = resolveHumanDelayWithBehavior(behavior.humanDelay, cooldownKey);
|
|
1270
|
+
const queuedBehavior = {
|
|
1271
|
+
...behavior,
|
|
1272
|
+
humanDelay: adjustedHumanDelay,
|
|
1273
|
+
};
|
|
1274
|
+
const wasExplicitlyAddressed = !msg.guild ||
|
|
1275
|
+
hasPrefixInvocation(msg.content || '') ||
|
|
1276
|
+
Boolean(client.user && msg.mentions.has(client.user)) ||
|
|
1277
|
+
Boolean(client.user && msg.mentions.repliedUser?.id === client.user.id);
|
|
1278
|
+
let clearAckReaction = async () => { };
|
|
1279
|
+
if (client.user && shouldApplyAckReaction(msg, behavior)) {
|
|
1280
|
+
const clearReaction = await addAckReaction({
|
|
1281
|
+
message: msg,
|
|
1282
|
+
emoji: behavior.ackReaction,
|
|
1283
|
+
withRetry: withDiscordRetry,
|
|
1284
|
+
botUserId: client.user.id,
|
|
1285
|
+
});
|
|
1286
|
+
clearAckReaction = behavior.removeAckAfterReply
|
|
1287
|
+
? clearReaction
|
|
1288
|
+
: async () => { };
|
|
1289
|
+
}
|
|
1290
|
+
const queued = {
|
|
1291
|
+
msg,
|
|
1292
|
+
content,
|
|
1293
|
+
behavior: queuedBehavior,
|
|
1294
|
+
clearAckReaction,
|
|
1295
|
+
wasExplicitlyAddressed,
|
|
1296
|
+
cooldownKey,
|
|
1297
|
+
};
|
|
771
1298
|
const existing = pendingBatches.get(key);
|
|
1299
|
+
const shouldDebounceMessage = shouldDebounceInbound({
|
|
1300
|
+
content: msg.content || '',
|
|
1301
|
+
hasAttachments: msg.attachments.size > 0,
|
|
1302
|
+
isPrefixedCommand: hasPrefixInvocation(msg.content || ''),
|
|
1303
|
+
});
|
|
772
1304
|
if (!existing) {
|
|
1305
|
+
const typingController = createTypingController(msg, behavior.typingMode);
|
|
1306
|
+
typingController.setPhase('received');
|
|
1307
|
+
const lifecycleController = client.user && DISCORD_LIFECYCLE_REACTIONS.enabled
|
|
1308
|
+
? new LifecycleReactionController({
|
|
1309
|
+
message: msg,
|
|
1310
|
+
withRetry: withDiscordRetry,
|
|
1311
|
+
botUserId: client.user.id,
|
|
1312
|
+
config: {
|
|
1313
|
+
enabled: DISCORD_LIFECYCLE_REACTIONS.enabled,
|
|
1314
|
+
removeOnComplete: DISCORD_LIFECYCLE_REACTIONS.removeOnComplete,
|
|
1315
|
+
phases: DISCORD_LIFECYCLE_REACTIONS.phases,
|
|
1316
|
+
},
|
|
1317
|
+
})
|
|
1318
|
+
: null;
|
|
1319
|
+
lifecycleController?.setPhase('queued');
|
|
1320
|
+
const baseDelayMs = shouldDebounceMessage ? behavior.debounceMs : 0;
|
|
1321
|
+
const startupStaggerMs = startupConnectedAtMs > 0 &&
|
|
1322
|
+
Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
|
|
1323
|
+
!wasExplicitlyAddressed
|
|
1324
|
+
? randomIntInRange(STARTUP_STAGGER_MIN_DELAY_MS, STARTUP_STAGGER_MAX_DELAY_MS)
|
|
1325
|
+
: 0;
|
|
1326
|
+
const delayMs = baseDelayMs + startupStaggerMs;
|
|
773
1327
|
const timer = setTimeout(() => {
|
|
774
1328
|
void dispatchConversationBatch(key);
|
|
775
|
-
},
|
|
1329
|
+
}, delayMs);
|
|
776
1330
|
pendingBatches.set(key, {
|
|
777
1331
|
items: [queued],
|
|
778
1332
|
timer,
|
|
1333
|
+
typingController,
|
|
1334
|
+
lifecycleController,
|
|
779
1335
|
});
|
|
780
1336
|
return;
|
|
781
1337
|
}
|
|
1338
|
+
existing.typingController.setPhase('received');
|
|
1339
|
+
existing.lifecycleController?.setPhase('queued');
|
|
782
1340
|
clearTimeout(existing.timer);
|
|
783
1341
|
existing.items.push(queued);
|
|
1342
|
+
const shouldFlushImmediately = !shouldDebounceMessage ||
|
|
1343
|
+
existing.items.length >= DEFAULT_DEBOUNCE_MAX_BUFFER;
|
|
1344
|
+
const baseDelayMs = shouldFlushImmediately ? 0 : behavior.debounceMs;
|
|
1345
|
+
const startupStaggerMs = startupConnectedAtMs > 0 &&
|
|
1346
|
+
Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
|
|
1347
|
+
!wasExplicitlyAddressed
|
|
1348
|
+
? randomIntInRange(STARTUP_STAGGER_MIN_DELAY_MS, STARTUP_STAGGER_MAX_DELAY_MS)
|
|
1349
|
+
: 0;
|
|
1350
|
+
const delayMs = baseDelayMs + startupStaggerMs;
|
|
784
1351
|
existing.timer = setTimeout(() => {
|
|
785
1352
|
void dispatchConversationBatch(key);
|
|
786
|
-
},
|
|
1353
|
+
}, delayMs);
|
|
787
1354
|
};
|
|
788
1355
|
const dropPendingMessage = async (messageId) => {
|
|
789
1356
|
for (const [key, pending] of pendingBatches) {
|
|
@@ -791,29 +1358,39 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
791
1358
|
if (index === -1)
|
|
792
1359
|
continue;
|
|
793
1360
|
const [removed] = pending.items.splice(index, 1);
|
|
794
|
-
await removed.
|
|
1361
|
+
await removed.clearAckReaction();
|
|
795
1362
|
if (pending.items.length === 0) {
|
|
796
1363
|
clearTimeout(pending.timer);
|
|
1364
|
+
pending.typingController.stop();
|
|
1365
|
+
await pending.lifecycleController?.clear();
|
|
797
1366
|
pendingBatches.delete(key);
|
|
798
1367
|
}
|
|
799
1368
|
return;
|
|
800
1369
|
}
|
|
801
1370
|
};
|
|
802
|
-
const updatePendingMessage = async (messageId, nextMsg, nextContent) => {
|
|
1371
|
+
const updatePendingMessage = async (messageId, nextMsg, nextContent, nextBehavior) => {
|
|
803
1372
|
for (const [key, pending] of pendingBatches) {
|
|
804
1373
|
const index = pending.items.findIndex((item) => item.msg.id === messageId);
|
|
805
1374
|
if (index === -1)
|
|
806
1375
|
continue;
|
|
807
1376
|
if (!nextContent) {
|
|
808
1377
|
const [removed] = pending.items.splice(index, 1);
|
|
809
|
-
await removed.
|
|
1378
|
+
await removed.clearAckReaction();
|
|
810
1379
|
}
|
|
811
1380
|
else {
|
|
812
1381
|
pending.items[index].msg = nextMsg;
|
|
813
1382
|
pending.items[index].content = nextContent;
|
|
1383
|
+
pending.items[index].behavior = nextBehavior;
|
|
1384
|
+
pending.items[index].wasExplicitlyAddressed =
|
|
1385
|
+
!nextMsg.guild ||
|
|
1386
|
+
hasPrefixInvocation(nextMsg.content || '') ||
|
|
1387
|
+
Boolean(client.user && nextMsg.mentions.has(client.user));
|
|
1388
|
+
pending.items[index].cooldownKey = buildConversationCooldownKey(nextMsg.channelId, nextMsg.author.id);
|
|
814
1389
|
}
|
|
815
1390
|
if (pending.items.length === 0) {
|
|
816
1391
|
clearTimeout(pending.timer);
|
|
1392
|
+
pending.typingController.stop();
|
|
1393
|
+
await pending.lifecycleController?.clear();
|
|
817
1394
|
pendingBatches.delete(key);
|
|
818
1395
|
}
|
|
819
1396
|
return true;
|
|
@@ -826,35 +1403,32 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
826
1403
|
const sessionId = getSessionId(msg);
|
|
827
1404
|
const guildId = msg.guild?.id || null;
|
|
828
1405
|
const channelId = msg.channelId;
|
|
1406
|
+
const behavior = resolveChannelBehavior(msg);
|
|
829
1407
|
const content = cleanIncomingContent(msg.content);
|
|
830
1408
|
observeMessageParticipants(msg, content);
|
|
831
1409
|
const immediateMentionLookup = buildMentionLookup([msg], [], msg.guild ? participantMemoryByChannel.get(msg.channelId) : undefined);
|
|
832
1410
|
const reply = async (text, files) => {
|
|
833
|
-
await sendChunkedReply(msg, text, files, immediateMentionLookup);
|
|
1411
|
+
await sendChunkedReply(msg, text, files, immediateMentionLookup, behavior.humanDelay);
|
|
834
1412
|
};
|
|
835
1413
|
const commandReply = async (text, files) => {
|
|
836
1414
|
try {
|
|
837
|
-
await sendChunkedDirectReply(msg, text, files, immediateMentionLookup);
|
|
1415
|
+
await sendChunkedDirectReply(msg, text, files, immediateMentionLookup, behavior.humanDelay);
|
|
838
1416
|
}
|
|
839
1417
|
catch (error) {
|
|
840
1418
|
logger.warn({ error, userId: msg.author.id, channelId: msg.channelId }, 'Failed to send command reply via DM; command response dropped');
|
|
841
1419
|
}
|
|
842
1420
|
};
|
|
843
1421
|
const parsed = parseCommand(msg.content);
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
const ignorePrefixCommand = prefixedToken === 'status';
|
|
1422
|
+
const hasPrefixedInvocation = hasPrefixInvocation(msg.content);
|
|
1423
|
+
const hasSlashInvocation = hasSlashCommandInvocation(msg.content);
|
|
1424
|
+
const hasCommandInvocation = hasPrefixedInvocation || hasSlashInvocation;
|
|
848
1425
|
if (DISCORD_COMMANDS_ONLY) {
|
|
849
|
-
if (!
|
|
1426
|
+
if (!hasCommandInvocation)
|
|
850
1427
|
return;
|
|
851
|
-
if (!isAuthorizedCommandUserId(msg.author.id)) {
|
|
1428
|
+
if (msg.guild && !isAuthorizedCommandUserId(msg.author.id)) {
|
|
852
1429
|
logger.debug({ userId: msg.author.id, channelId: msg.channelId }, 'Ignoring unauthorized Discord command in commands-only mode');
|
|
853
1430
|
return;
|
|
854
1431
|
}
|
|
855
|
-
if (ignorePrefixCommand) {
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
1432
|
if (!parsed.isCommand) {
|
|
859
1433
|
if (!content) {
|
|
860
1434
|
await commandReply(`How can I help? Try \`${DISCORD_PREFIX} help\`.`);
|
|
@@ -864,28 +1438,37 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
864
1438
|
}
|
|
865
1439
|
return;
|
|
866
1440
|
}
|
|
867
|
-
await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
|
|
1441
|
+
await commandHandler(sessionId, guildId, channelId, msg.author.id, msg.author.username, [parsed.command, ...parsed.args], commandReply);
|
|
868
1442
|
return;
|
|
869
1443
|
}
|
|
870
|
-
if (
|
|
1444
|
+
if (msg.guild &&
|
|
1445
|
+
hasCommandInvocation &&
|
|
1446
|
+
!isAuthorizedCommandUserId(msg.author.id)) {
|
|
1447
|
+
logger.debug({ userId: msg.author.id, channelId: msg.channelId }, 'Ignoring unauthorized Discord prefixed command');
|
|
871
1448
|
return;
|
|
872
|
-
|
|
1449
|
+
}
|
|
1450
|
+
if (!isTrigger(msg, behavior))
|
|
1451
|
+
return;
|
|
1452
|
+
if (parsed.isCommand && hasCommandInvocation) {
|
|
1453
|
+
await commandHandler(sessionId, guildId, channelId, msg.author.id, msg.author.username, [parsed.command, ...parsed.args], commandReply);
|
|
873
1454
|
return;
|
|
874
1455
|
}
|
|
875
|
-
if (
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
}
|
|
879
|
-
else {
|
|
880
|
-
await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
1456
|
+
if (!shouldHandleFreeModeMessage(msg, behavior, content)) {
|
|
1457
|
+
logger.debug({ channelId: msg.channelId, messageId: msg.id, userId: msg.author.id }, 'Skipping Discord free-mode message by relevance/mention gate');
|
|
1458
|
+
return;
|
|
883
1459
|
}
|
|
884
1460
|
if (!content) {
|
|
885
1461
|
await reply('How can I help? Send me a message or try `!claw help`.');
|
|
886
1462
|
return;
|
|
887
1463
|
}
|
|
888
|
-
await
|
|
1464
|
+
const readWithoutReplyHandled = await maybeHandleReadWithoutReply(msg, content);
|
|
1465
|
+
if (readWithoutReplyHandled) {
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const rateLimitAllowed = await enforcePerUserRateLimit(msg, behavior);
|
|
1469
|
+
if (!rateLimitAllowed)
|
|
1470
|
+
return;
|
|
1471
|
+
await queueConversationMessage(msg, content, behavior);
|
|
889
1472
|
});
|
|
890
1473
|
client.on('messageUpdate', async (_oldMsg, nextMsg) => {
|
|
891
1474
|
if (DISCORD_COMMANDS_ONLY)
|
|
@@ -898,15 +1481,28 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
898
1481
|
if (fetched.author?.bot)
|
|
899
1482
|
return;
|
|
900
1483
|
const updatedContent = cleanIncomingContent(fetched.content || '');
|
|
1484
|
+
const behavior = resolveChannelBehavior(fetched);
|
|
901
1485
|
observeMessageParticipants(fetched, updatedContent);
|
|
902
|
-
if (!isTrigger(fetched))
|
|
1486
|
+
if (!isTrigger(fetched, behavior)) {
|
|
1487
|
+
await updatePendingMessage(fetched.id, fetched, '', behavior);
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
if (hasPrefixInvocation(fetched.content || '') &&
|
|
1491
|
+
!isAuthorizedCommandUserId(fetched.author.id)) {
|
|
1492
|
+
await updatePendingMessage(fetched.id, fetched, '', behavior);
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (!shouldHandleFreeModeMessage(fetched, behavior, updatedContent)) {
|
|
1496
|
+
await updatePendingMessage(fetched.id, fetched, '', behavior);
|
|
903
1497
|
return;
|
|
904
|
-
|
|
1498
|
+
}
|
|
1499
|
+
await updatePendingMessage(fetched.id, fetched, updatedContent, behavior);
|
|
905
1500
|
const inFlight = inFlightByMessageId.get(fetched.id);
|
|
906
1501
|
if (!inFlight || inFlight.aborted)
|
|
907
1502
|
return;
|
|
908
1503
|
inFlight.aborted = true;
|
|
909
1504
|
inFlight.abortController.abort();
|
|
1505
|
+
inFlight.emitLifecyclePhase('error');
|
|
910
1506
|
for (const messageId of inFlight.messageIds) {
|
|
911
1507
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
912
1508
|
inFlightByMessageId.delete(messageId);
|
|
@@ -914,7 +1510,10 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
914
1510
|
}
|
|
915
1511
|
await inFlight.stream.discard();
|
|
916
1512
|
if (updatedContent) {
|
|
917
|
-
await
|
|
1513
|
+
const rateLimitAllowed = await enforcePerUserRateLimit(fetched, behavior);
|
|
1514
|
+
if (!rateLimitAllowed)
|
|
1515
|
+
return;
|
|
1516
|
+
await queueConversationMessage(fetched, updatedContent, behavior);
|
|
918
1517
|
}
|
|
919
1518
|
});
|
|
920
1519
|
client.on('messageDelete', async (msg) => {
|
|
@@ -924,6 +1523,7 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
924
1523
|
return;
|
|
925
1524
|
inFlight.aborted = true;
|
|
926
1525
|
inFlight.abortController.abort();
|
|
1526
|
+
inFlight.emitLifecyclePhase('error');
|
|
927
1527
|
for (const messageId of inFlight.messageIds) {
|
|
928
1528
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
929
1529
|
inFlightByMessageId.delete(messageId);
|
|
@@ -956,6 +1556,11 @@ export function initDiscord(onMessage, onCommand) {
|
|
|
956
1556
|
client.login(DISCORD_TOKEN);
|
|
957
1557
|
return client;
|
|
958
1558
|
}
|
|
1559
|
+
export async function setDiscordMaintenancePresence() {
|
|
1560
|
+
if (!presenceController)
|
|
1561
|
+
return;
|
|
1562
|
+
await presenceController.setMaintenance();
|
|
1563
|
+
}
|
|
959
1564
|
/**
|
|
960
1565
|
* Send a message to a channel by ID (used by scheduler).
|
|
961
1566
|
*/
|