@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,53 +1,90 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
type ApplicationCommandDataResolvable,
|
|
3
|
+
ApplicationCommandOptionType,
|
|
4
|
+
type AttachmentBuilder,
|
|
4
5
|
Client,
|
|
5
|
-
GatewayIntentBits,
|
|
6
6
|
type Message as DiscordMessage,
|
|
7
|
+
GatewayIntentBits,
|
|
7
8
|
Partials,
|
|
8
9
|
} from 'discord.js';
|
|
9
10
|
|
|
10
11
|
import {
|
|
12
|
+
DISCORD_ACK_REACTION,
|
|
13
|
+
DISCORD_ACK_REACTION_SCOPE,
|
|
11
14
|
DISCORD_COMMAND_USER_ID,
|
|
12
15
|
DISCORD_COMMANDS_ONLY,
|
|
16
|
+
DISCORD_DEBOUNCE_MS,
|
|
17
|
+
DISCORD_FREE_RESPONSE_CHANNELS,
|
|
18
|
+
DISCORD_GROUP_POLICY,
|
|
13
19
|
DISCORD_GUILD_MEMBERS_INTENT,
|
|
14
|
-
|
|
20
|
+
DISCORD_GUILDS,
|
|
21
|
+
DISCORD_HUMAN_DELAY,
|
|
22
|
+
DISCORD_LIFECYCLE_REACTIONS,
|
|
23
|
+
DISCORD_MAX_CONCURRENT_PER_CHANNEL,
|
|
15
24
|
DISCORD_PREFIX,
|
|
25
|
+
DISCORD_PRESENCE_INTENT,
|
|
26
|
+
DISCORD_RATE_LIMIT_EXEMPT_ROLES,
|
|
27
|
+
DISCORD_RATE_LIMIT_PER_USER,
|
|
28
|
+
DISCORD_REMOVE_ACK_AFTER_REPLY,
|
|
16
29
|
DISCORD_RESPOND_TO_ALL_MESSAGES,
|
|
30
|
+
DISCORD_SELF_PRESENCE,
|
|
31
|
+
DISCORD_SUPPRESS_PATTERNS,
|
|
17
32
|
DISCORD_TOKEN,
|
|
33
|
+
DISCORD_TYPING_MODE,
|
|
18
34
|
} from '../../config.js';
|
|
35
|
+
import { logger } from '../../logger.js';
|
|
36
|
+
import type { MediaContextItem } from '../../types.js';
|
|
19
37
|
import { buildAttachmentContext } from './attachments.js';
|
|
38
|
+
import {
|
|
39
|
+
DEFAULT_DEBOUNCE_MAX_BUFFER,
|
|
40
|
+
resolveInboundDebounceMs,
|
|
41
|
+
shouldDebounceInbound,
|
|
42
|
+
} from './debounce.js';
|
|
43
|
+
import {
|
|
44
|
+
formatError,
|
|
45
|
+
prepareChunkedPayloads,
|
|
46
|
+
sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery,
|
|
47
|
+
sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery,
|
|
48
|
+
sendChunkedReply as sendChunkedReplyFromDelivery,
|
|
49
|
+
} from './delivery.js';
|
|
50
|
+
import type { HumanDelayConfig } from './human-delay.js';
|
|
20
51
|
import {
|
|
21
52
|
buildSessionIdFromContext as buildSessionIdFromContextInbound,
|
|
22
53
|
cleanIncomingContent as cleanIncomingContentInbound,
|
|
54
|
+
type DiscordGuildMessageMode,
|
|
23
55
|
hasPrefixInvocation as hasPrefixInvocationInbound,
|
|
24
56
|
isTrigger as isTriggerInbound,
|
|
25
|
-
parseCommand as parseCommandInbound,
|
|
26
57
|
type ParsedCommand,
|
|
58
|
+
parseCommand as parseCommandInbound,
|
|
27
59
|
} from './inbound.js';
|
|
28
60
|
import {
|
|
29
61
|
addMentionAlias,
|
|
30
62
|
extractMentionAliasHints,
|
|
31
|
-
normalizeMentionAlias,
|
|
32
63
|
type MentionLookup,
|
|
64
|
+
normalizeMentionAlias,
|
|
33
65
|
} from './mentions.js';
|
|
34
66
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
67
|
+
DiscordAutoPresenceController,
|
|
68
|
+
type PresenceHealthState,
|
|
69
|
+
} from './presence.js';
|
|
70
|
+
import { SlidingWindowRateLimiter } from './rate-limiter.js';
|
|
71
|
+
import {
|
|
72
|
+
addAckReaction,
|
|
73
|
+
type LifecyclePhase,
|
|
74
|
+
LifecycleReactionController,
|
|
75
|
+
} from './reactions.js';
|
|
76
|
+
import { DiscordStreamManager } from './stream.js';
|
|
41
77
|
import {
|
|
42
|
-
createDiscordToolActionRunner,
|
|
43
78
|
type CachedDiscordPresence,
|
|
79
|
+
createDiscordToolActionRunner,
|
|
44
80
|
type DiscordToolActionRequest,
|
|
45
81
|
} from './tool-actions.js';
|
|
46
|
-
import {
|
|
47
|
-
import { logger } from '../../logger.js';
|
|
48
|
-
import type { MediaContextItem } from '../../types.js';
|
|
82
|
+
import { createTypingController } from './typing.js';
|
|
49
83
|
|
|
50
|
-
export type ReplyFn = (
|
|
84
|
+
export type ReplyFn = (
|
|
85
|
+
content: string,
|
|
86
|
+
files?: AttachmentBuilder[],
|
|
87
|
+
) => Promise<void>;
|
|
51
88
|
|
|
52
89
|
interface PendingGuildHistoryEntry {
|
|
53
90
|
messageId: string;
|
|
@@ -70,6 +107,7 @@ export interface MessageRunContext {
|
|
|
70
107
|
abortSignal: AbortSignal;
|
|
71
108
|
stream: DiscordStreamManager;
|
|
72
109
|
mentionLookup: MentionLookup;
|
|
110
|
+
emitLifecyclePhase: (phase: LifecyclePhase) => void;
|
|
73
111
|
}
|
|
74
112
|
|
|
75
113
|
export type MessageHandler = (
|
|
@@ -97,7 +135,6 @@ let messageHandler: MessageHandler;
|
|
|
97
135
|
let commandHandler: CommandHandler;
|
|
98
136
|
let activeConversationRuns = 0;
|
|
99
137
|
let botMentionRegex: RegExp | null = null;
|
|
100
|
-
const MESSAGE_DEBOUNCE_MS = 2_500;
|
|
101
138
|
const DISCORD_RETRY_MAX_ATTEMPTS = 3;
|
|
102
139
|
const DISCORD_RETRY_BASE_DELAY_MS = 500;
|
|
103
140
|
const GUILD_INBOUND_HISTORY_LIMIT = 20;
|
|
@@ -107,8 +144,44 @@ const PARTICIPANT_MEMORY_MAX_CHANNELS = 200;
|
|
|
107
144
|
const PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL = 200;
|
|
108
145
|
const PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER = 8;
|
|
109
146
|
const MAX_PRESENCE_CACHE_USERS = 5_000;
|
|
147
|
+
const RATE_LIMIT_NOTIFY_COOLDOWN_MS = 12_000;
|
|
148
|
+
const CONCURRENCY_RETRY_DELAY_MS = 250;
|
|
149
|
+
const PRESENCE_WINDOW_MS = 5 * 60_000;
|
|
150
|
+
const PRESENCE_DEGRADED_DURATION_MS = 45_000;
|
|
151
|
+
const PRESENCE_EXHAUSTED_ERROR_RE =
|
|
152
|
+
/(api down|unavailable|rate limit|too many active containers|quota|token limit|timeout)/i;
|
|
153
|
+
const FRIENDLY_RATE_LIMIT_MESSAGE =
|
|
154
|
+
"You're sending messages too fast — give me a moment to catch up!";
|
|
155
|
+
const READ_WITHOUT_REPLY_RE =
|
|
156
|
+
/^(thanks|thank you|thx|ty|got it|ok|okay|cool|perfect|awesome|sounds good|roger)[!. ]*$/i;
|
|
157
|
+
const READ_WITHOUT_REPLY_PROBABILITY = 0.6;
|
|
158
|
+
const STARTUP_STAGGER_WINDOW_MS = 120_000;
|
|
159
|
+
const STARTUP_STAGGER_MIN_DELAY_MS = 500;
|
|
160
|
+
const STARTUP_STAGGER_MAX_DELAY_MS = 3_500;
|
|
161
|
+
const SELECTIVE_SILENCE_BASE_PROBABILITY = 0.25;
|
|
162
|
+
const SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY = 0.5;
|
|
163
|
+
const SELECTIVE_SILENCE_RECENT_WINDOW_MS = 60_000;
|
|
164
|
+
const NIGHT_HOURS_START = 22;
|
|
165
|
+
const NIGHT_HOURS_END = 7;
|
|
166
|
+
const CONVERSATION_COOLDOWN_RESET_MS = 20 * 60_000;
|
|
167
|
+
const CONVERSATION_COOLDOWN_THRESHOLD = 5;
|
|
168
|
+
const CONVERSATION_COOLDOWN_MAX_FACTOR = 2.5;
|
|
110
169
|
|
|
111
170
|
const discordPresenceCache = new Map<string, CachedDiscordPresence>();
|
|
171
|
+
const userRateLimiter = new SlidingWindowRateLimiter(60_000);
|
|
172
|
+
const recentConversationMetrics: Array<{
|
|
173
|
+
atMs: number;
|
|
174
|
+
durationMs: number;
|
|
175
|
+
ok: boolean;
|
|
176
|
+
exhaustedHint: boolean;
|
|
177
|
+
}> = [];
|
|
178
|
+
let consecutiveConversationFailures = 0;
|
|
179
|
+
let presenceController: DiscordAutoPresenceController | null = null;
|
|
180
|
+
let startupConnectedAtMs = 0;
|
|
181
|
+
const conversationExchangeByKey = new Map<
|
|
182
|
+
string,
|
|
183
|
+
{ count: number; lastAtMs: number }
|
|
184
|
+
>();
|
|
112
185
|
|
|
113
186
|
function setDiscordPresence(userId: string, data: CachedDiscordPresence): void {
|
|
114
187
|
discordPresenceCache.set(userId, data);
|
|
@@ -132,7 +205,10 @@ function buildMentionLookup(
|
|
|
132
205
|
const lookup: MentionLookup = { byAlias: new Map<string, Set<string>>() };
|
|
133
206
|
const botUserId = client.user?.id || '';
|
|
134
207
|
|
|
135
|
-
const addUser = (
|
|
208
|
+
const addUser = (
|
|
209
|
+
userId: string,
|
|
210
|
+
aliases: Array<string | null | undefined>,
|
|
211
|
+
): void => {
|
|
136
212
|
if (!userId || userId === botUserId) return;
|
|
137
213
|
for (const alias of aliases) {
|
|
138
214
|
addMentionAlias(lookup, alias, userId);
|
|
@@ -147,7 +223,8 @@ function buildMentionLookup(
|
|
|
147
223
|
for (const mentioned of msg.mentions.users.values()) {
|
|
148
224
|
const aliases = [mentioned.username];
|
|
149
225
|
const mentionedMember = msg.mentions.members?.get(mentioned.id);
|
|
150
|
-
if (mentionedMember?.displayName)
|
|
226
|
+
if (mentionedMember?.displayName)
|
|
227
|
+
aliases.push(mentionedMember.displayName);
|
|
151
228
|
addUser(mentioned.id, aliases);
|
|
152
229
|
}
|
|
153
230
|
|
|
@@ -176,18 +253,25 @@ function summarizePendingHistoryEntry(entry: PendingGuildHistoryEntry): string {
|
|
|
176
253
|
const author = entry.displayName || entry.username || 'user';
|
|
177
254
|
const authorLabel = entry.isBot ? `${author} [bot]` : author;
|
|
178
255
|
const content = entry.content.trim();
|
|
179
|
-
const snippet =
|
|
256
|
+
const snippet =
|
|
257
|
+
content.length > 300 ? `${content.slice(0, 297)}...` : content;
|
|
180
258
|
return `${authorLabel}: ${snippet}`;
|
|
181
259
|
}
|
|
182
260
|
|
|
183
|
-
function buildPendingHistoryContext(
|
|
261
|
+
function buildPendingHistoryContext(
|
|
262
|
+
entries: PendingGuildHistoryEntry[],
|
|
263
|
+
): string {
|
|
184
264
|
if (entries.length === 0) return '';
|
|
185
265
|
const selected: string[] = [];
|
|
186
266
|
let totalChars = 0;
|
|
187
267
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
188
268
|
const line = summarizePendingHistoryEntry(entries[i]);
|
|
189
269
|
if (!line) continue;
|
|
190
|
-
if (
|
|
270
|
+
if (
|
|
271
|
+
totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS &&
|
|
272
|
+
selected.length > 0
|
|
273
|
+
)
|
|
274
|
+
break;
|
|
191
275
|
selected.push(line);
|
|
192
276
|
totalChars += line.length + 1;
|
|
193
277
|
}
|
|
@@ -206,10 +290,13 @@ async function buildInboundHistorySnapshot(
|
|
|
206
290
|
msg: DiscordMessage,
|
|
207
291
|
excludeMessageIds: Set<string>,
|
|
208
292
|
): Promise<{ entries: PendingGuildHistoryEntry[]; context: string }> {
|
|
209
|
-
if (!msg.guild || !('messages' in msg.channel))
|
|
293
|
+
if (!msg.guild || !('messages' in msg.channel))
|
|
294
|
+
return { entries: [], context: '' };
|
|
210
295
|
|
|
211
296
|
try {
|
|
212
|
-
const recentMessages = await msg.channel.messages.fetch({
|
|
297
|
+
const recentMessages = await msg.channel.messages.fetch({
|
|
298
|
+
limit: GUILD_INBOUND_HISTORY_LIMIT,
|
|
299
|
+
});
|
|
213
300
|
const entries: PendingGuildHistoryEntry[] = [];
|
|
214
301
|
let hiddenTextCount = 0;
|
|
215
302
|
let hiddenBotTextCount = 0;
|
|
@@ -219,7 +306,11 @@ async function buildInboundHistorySnapshot(
|
|
|
219
306
|
if (plainText) return plainText;
|
|
220
307
|
|
|
221
308
|
const embedChunks = recent.embeds
|
|
222
|
-
.map((embed) =>
|
|
309
|
+
.map((embed) =>
|
|
310
|
+
[embed.title?.trim(), embed.description?.trim()]
|
|
311
|
+
.filter(Boolean)
|
|
312
|
+
.join(' — '),
|
|
313
|
+
)
|
|
223
314
|
.map((part) => part.trim())
|
|
224
315
|
.filter(Boolean)
|
|
225
316
|
.slice(0, 3);
|
|
@@ -235,7 +326,9 @@ async function buildInboundHistorySnapshot(
|
|
|
235
326
|
return `[attachments] ${attachmentNames.join(', ')}`;
|
|
236
327
|
}
|
|
237
328
|
|
|
238
|
-
const systemContent = recent.system
|
|
329
|
+
const systemContent = recent.system
|
|
330
|
+
? (recent.cleanContent || '').trim()
|
|
331
|
+
: '';
|
|
239
332
|
if (systemContent) return `[system] ${systemContent}`;
|
|
240
333
|
|
|
241
334
|
hiddenTextCount += 1;
|
|
@@ -255,11 +348,16 @@ async function buildInboundHistorySnapshot(
|
|
|
255
348
|
username: recent.author.username || 'user',
|
|
256
349
|
displayName: recent.member?.displayName || null,
|
|
257
350
|
isBot: Boolean(recent.author.bot),
|
|
258
|
-
timestampMs: Number.isFinite(recent.createdTimestamp)
|
|
351
|
+
timestampMs: Number.isFinite(recent.createdTimestamp)
|
|
352
|
+
? recent.createdTimestamp
|
|
353
|
+
: 0,
|
|
259
354
|
content,
|
|
260
355
|
});
|
|
261
356
|
}
|
|
262
|
-
entries.sort(
|
|
357
|
+
entries.sort(
|
|
358
|
+
(a, b) =>
|
|
359
|
+
a.timestampMs - b.timestampMs || a.messageId.localeCompare(b.messageId),
|
|
360
|
+
);
|
|
263
361
|
let context = buildPendingHistoryContext(entries);
|
|
264
362
|
if (hiddenTextCount > 0) {
|
|
265
363
|
const visibilityNote = [
|
|
@@ -276,18 +374,26 @@ async function buildInboundHistorySnapshot(
|
|
|
276
374
|
context,
|
|
277
375
|
};
|
|
278
376
|
} catch (error) {
|
|
279
|
-
logger.debug(
|
|
377
|
+
logger.debug(
|
|
378
|
+
{ error, guildId: msg.guild.id, channelId: msg.channelId },
|
|
379
|
+
'Failed to build inbound channel history snapshot',
|
|
380
|
+
);
|
|
280
381
|
return { entries: [], context: '' };
|
|
281
382
|
}
|
|
282
383
|
}
|
|
283
384
|
|
|
284
|
-
function addParticipantAlias(
|
|
385
|
+
function addParticipantAlias(
|
|
386
|
+
info: ParticipantInfo,
|
|
387
|
+
alias: string | null | undefined,
|
|
388
|
+
): void {
|
|
285
389
|
const normalized = normalizeMentionAlias(alias);
|
|
286
390
|
if (!normalized) return;
|
|
287
391
|
info.aliases.add(normalized);
|
|
288
392
|
}
|
|
289
393
|
|
|
290
|
-
function formatDiscordHandleFromAlias(
|
|
394
|
+
function formatDiscordHandleFromAlias(
|
|
395
|
+
alias: string | null | undefined,
|
|
396
|
+
): string | null {
|
|
291
397
|
const normalized = normalizeMentionAlias(alias);
|
|
292
398
|
if (!normalized) return null;
|
|
293
399
|
return `@${normalized}`;
|
|
@@ -370,7 +476,8 @@ function buildParticipantContext(
|
|
|
370
476
|
.slice(0, PARTICIPANT_CONTEXT_MAX_USERS)
|
|
371
477
|
.map((entry) => {
|
|
372
478
|
const aliases = Array.from(entry.aliases).slice(0, 3);
|
|
373
|
-
const preferredHandle =
|
|
479
|
+
const preferredHandle =
|
|
480
|
+
formatDiscordHandleFromAlias(aliases[0]) || `id:${entry.id}`;
|
|
374
481
|
const botSuffix = botParticipantIds.has(entry.id) ? ' [bot]' : '';
|
|
375
482
|
return `- ${preferredHandle}${botSuffix} id:${entry.id} aliases: ${aliases.join(', ')}`;
|
|
376
483
|
});
|
|
@@ -416,7 +523,11 @@ export async function runDiscordToolAction(
|
|
|
416
523
|
}
|
|
417
524
|
|
|
418
525
|
function getSessionId(msg: DiscordMessage): string {
|
|
419
|
-
return buildSessionIdFromContext(
|
|
526
|
+
return buildSessionIdFromContext(
|
|
527
|
+
msg.guild?.id ?? null,
|
|
528
|
+
msg.channelId,
|
|
529
|
+
msg.author.id,
|
|
530
|
+
);
|
|
420
531
|
}
|
|
421
532
|
|
|
422
533
|
function hasPrefixInvocation(content: string): boolean {
|
|
@@ -429,22 +540,123 @@ function isAuthorizedCommandUserId(userId: string): boolean {
|
|
|
429
540
|
return userId === configuredUserId;
|
|
430
541
|
}
|
|
431
542
|
|
|
432
|
-
function buildSessionIdFromContext(
|
|
543
|
+
function buildSessionIdFromContext(
|
|
544
|
+
guildId: string | null,
|
|
545
|
+
channelId: string,
|
|
546
|
+
userId: string,
|
|
547
|
+
): string {
|
|
433
548
|
return buildSessionIdFromContextInbound(guildId, channelId, userId);
|
|
434
549
|
}
|
|
435
550
|
|
|
436
|
-
|
|
551
|
+
interface ResolvedChannelBehavior {
|
|
552
|
+
guildMessageMode: DiscordGuildMessageMode;
|
|
553
|
+
typingMode: 'instant' | 'thinking' | 'streaming' | 'never';
|
|
554
|
+
debounceMs: number;
|
|
555
|
+
ackReaction: string;
|
|
556
|
+
ackReactionScope: 'all' | 'group-mentions' | 'direct' | 'off';
|
|
557
|
+
removeAckAfterReply: boolean;
|
|
558
|
+
humanDelay: HumanDelayConfig;
|
|
559
|
+
rateLimitPerUser: number;
|
|
560
|
+
suppressPatterns: string[];
|
|
561
|
+
maxConcurrentPerChannel: number;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function resolveGuildMessageMode(msg: DiscordMessage): DiscordGuildMessageMode {
|
|
565
|
+
if (!msg.guild) return 'free';
|
|
566
|
+
if (DISCORD_GROUP_POLICY === 'disabled') return 'off';
|
|
567
|
+
|
|
568
|
+
const guildConfig = DISCORD_GUILDS[msg.guild.id];
|
|
569
|
+
const explicitMode = guildConfig?.channels[msg.channelId]?.mode;
|
|
570
|
+
if (DISCORD_GROUP_POLICY === 'allowlist') {
|
|
571
|
+
return explicitMode ?? 'off';
|
|
572
|
+
}
|
|
573
|
+
if (explicitMode) return explicitMode;
|
|
574
|
+
if (DISCORD_FREE_RESPONSE_CHANNELS.includes(msg.channelId)) return 'free';
|
|
575
|
+
if (guildConfig) return guildConfig.defaultMode;
|
|
576
|
+
if (DISCORD_RESPOND_TO_ALL_MESSAGES) return 'free';
|
|
577
|
+
return 'mention';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function resolveChannelBehavior(msg: DiscordMessage): ResolvedChannelBehavior {
|
|
581
|
+
const guildConfig = msg.guild ? DISCORD_GUILDS[msg.guild.id] : undefined;
|
|
582
|
+
const channelConfig = guildConfig?.channels[msg.channelId];
|
|
583
|
+
return {
|
|
584
|
+
guildMessageMode: resolveGuildMessageMode(msg),
|
|
585
|
+
typingMode: channelConfig?.typingMode ?? DISCORD_TYPING_MODE,
|
|
586
|
+
debounceMs: resolveInboundDebounceMs(
|
|
587
|
+
DISCORD_DEBOUNCE_MS,
|
|
588
|
+
channelConfig?.debounceMs,
|
|
589
|
+
),
|
|
590
|
+
ackReaction:
|
|
591
|
+
(channelConfig?.ackReaction ?? DISCORD_ACK_REACTION).trim() ||
|
|
592
|
+
DISCORD_ACK_REACTION,
|
|
593
|
+
ackReactionScope:
|
|
594
|
+
channelConfig?.ackReactionScope ?? DISCORD_ACK_REACTION_SCOPE,
|
|
595
|
+
removeAckAfterReply:
|
|
596
|
+
channelConfig?.removeAckAfterReply ?? DISCORD_REMOVE_ACK_AFTER_REPLY,
|
|
597
|
+
humanDelay: channelConfig?.humanDelay ?? DISCORD_HUMAN_DELAY,
|
|
598
|
+
rateLimitPerUser: Math.max(
|
|
599
|
+
0,
|
|
600
|
+
channelConfig?.rateLimitPerUser ?? DISCORD_RATE_LIMIT_PER_USER,
|
|
601
|
+
),
|
|
602
|
+
suppressPatterns:
|
|
603
|
+
channelConfig?.suppressPatterns ?? DISCORD_SUPPRESS_PATTERNS,
|
|
604
|
+
maxConcurrentPerChannel: Math.max(
|
|
605
|
+
1,
|
|
606
|
+
channelConfig?.maxConcurrentPerChannel ??
|
|
607
|
+
DISCORD_MAX_CONCURRENT_PER_CHANNEL,
|
|
608
|
+
),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function isTrigger(
|
|
613
|
+
msg: DiscordMessage,
|
|
614
|
+
behavior: ResolvedChannelBehavior,
|
|
615
|
+
): boolean {
|
|
437
616
|
return isTriggerInbound({
|
|
438
617
|
content: msg.content,
|
|
439
618
|
isDm: !msg.guild,
|
|
440
619
|
commandsOnly: DISCORD_COMMANDS_ONLY,
|
|
441
620
|
respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
|
|
621
|
+
guildMessageMode: behavior.guildMessageMode,
|
|
442
622
|
prefix: DISCORD_PREFIX,
|
|
443
623
|
botMentionRegex,
|
|
444
624
|
hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
|
|
625
|
+
suppressPatterns: behavior.suppressPatterns,
|
|
445
626
|
});
|
|
446
627
|
}
|
|
447
628
|
|
|
629
|
+
function shouldApplyAckReaction(
|
|
630
|
+
msg: DiscordMessage,
|
|
631
|
+
behavior: ResolvedChannelBehavior,
|
|
632
|
+
): boolean {
|
|
633
|
+
const scope = behavior.ackReactionScope;
|
|
634
|
+
if (scope === 'off') return false;
|
|
635
|
+
if (scope === 'all') return true;
|
|
636
|
+
if (scope === 'direct') return !msg.guild;
|
|
637
|
+
if (!msg.guild || !client.user) return false;
|
|
638
|
+
return msg.mentions.has(client.user);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function isRateLimitExempt(msg: DiscordMessage): boolean {
|
|
642
|
+
if (msg.author.id === DISCORD_COMMAND_USER_ID.trim()) return true;
|
|
643
|
+
if (!msg.guild) return false;
|
|
644
|
+
if (!msg.member || DISCORD_RATE_LIMIT_EXEMPT_ROLES.length === 0) return false;
|
|
645
|
+
|
|
646
|
+
const exemptByName = new Set(
|
|
647
|
+
DISCORD_RATE_LIMIT_EXEMPT_ROLES.map((role) =>
|
|
648
|
+
role.trim().toLowerCase(),
|
|
649
|
+
).filter(Boolean),
|
|
650
|
+
);
|
|
651
|
+
if (exemptByName.size === 0) return false;
|
|
652
|
+
|
|
653
|
+
for (const role of msg.member.roles.cache.values()) {
|
|
654
|
+
const normalized = role.name.trim().toLowerCase();
|
|
655
|
+
if (exemptByName.has(normalized)) return true;
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
|
|
448
660
|
function parseCommand(content: string): ParsedCommand {
|
|
449
661
|
return parseCommandInbound(content, botMentionRegex, DISCORD_PREFIX);
|
|
450
662
|
}
|
|
@@ -452,19 +664,29 @@ function parseCommand(content: string): ParsedCommand {
|
|
|
452
664
|
function isRetryableDiscordError(error: unknown): boolean {
|
|
453
665
|
const maybe = error as DiscordErrorLike;
|
|
454
666
|
const status = maybe.status ?? maybe.httpStatus;
|
|
455
|
-
return
|
|
667
|
+
return (
|
|
668
|
+
status === 429 ||
|
|
669
|
+
(typeof status === 'number' && status >= 500 && status <= 599)
|
|
670
|
+
);
|
|
456
671
|
}
|
|
457
672
|
|
|
458
673
|
function retryDelayMs(error: unknown, fallbackMs: number): number {
|
|
459
674
|
const maybe = error as DiscordErrorLike;
|
|
460
675
|
const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
|
|
461
|
-
if (
|
|
676
|
+
if (
|
|
677
|
+
typeof retryAfterSeconds === 'number' &&
|
|
678
|
+
Number.isFinite(retryAfterSeconds) &&
|
|
679
|
+
retryAfterSeconds > 0
|
|
680
|
+
) {
|
|
462
681
|
return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
|
|
463
682
|
}
|
|
464
683
|
return fallbackMs + Math.floor(Math.random() * 250);
|
|
465
684
|
}
|
|
466
685
|
|
|
467
|
-
async function withDiscordRetry<T>(
|
|
686
|
+
async function withDiscordRetry<T>(
|
|
687
|
+
label: string,
|
|
688
|
+
fn: () => Promise<T>,
|
|
689
|
+
): Promise<T> {
|
|
468
690
|
let attempt = 0;
|
|
469
691
|
let delayMs = DISCORD_RETRY_BASE_DELAY_MS;
|
|
470
692
|
while (true) {
|
|
@@ -472,11 +694,17 @@ async function withDiscordRetry<T>(label: string, fn: () => Promise<T>): Promise
|
|
|
472
694
|
try {
|
|
473
695
|
return await fn();
|
|
474
696
|
} catch (error) {
|
|
475
|
-
if (
|
|
697
|
+
if (
|
|
698
|
+
attempt >= DISCORD_RETRY_MAX_ATTEMPTS ||
|
|
699
|
+
!isRetryableDiscordError(error)
|
|
700
|
+
) {
|
|
476
701
|
throw error;
|
|
477
702
|
}
|
|
478
703
|
const waitMs = retryDelayMs(error, delayMs);
|
|
479
|
-
logger.warn(
|
|
704
|
+
logger.warn(
|
|
705
|
+
{ label, attempt, waitMs, error },
|
|
706
|
+
'Discord API call failed; retrying',
|
|
707
|
+
);
|
|
480
708
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
481
709
|
delayMs = Math.min(delayMs * 2, 4_000);
|
|
482
710
|
}
|
|
@@ -490,7 +718,8 @@ function cleanIncomingContent(content: string): string {
|
|
|
490
718
|
function summarizeContextMessage(msg: DiscordMessage): string {
|
|
491
719
|
const author = msg.author?.username || 'user';
|
|
492
720
|
const content = (msg.content || '').trim();
|
|
493
|
-
const snippet =
|
|
721
|
+
const snippet =
|
|
722
|
+
content.length > 500 ? `${content.slice(0, 497)}...` : content;
|
|
494
723
|
return `${author}: ${snippet || '(no text)'}`;
|
|
495
724
|
}
|
|
496
725
|
|
|
@@ -503,16 +732,25 @@ function buildChannelInfoContext(msg: DiscordMessage): string {
|
|
|
503
732
|
`- channel_id: ${msg.channelId}`,
|
|
504
733
|
];
|
|
505
734
|
|
|
506
|
-
const namedChannel = msg.channel as unknown as {
|
|
507
|
-
|
|
735
|
+
const namedChannel = msg.channel as unknown as {
|
|
736
|
+
name?: string;
|
|
737
|
+
topic?: string;
|
|
738
|
+
parent?: { name?: string | null } | null;
|
|
739
|
+
};
|
|
740
|
+
const channelName =
|
|
741
|
+
typeof namedChannel.name === 'string' ? namedChannel.name.trim() : '';
|
|
508
742
|
if (channelName) {
|
|
509
743
|
lines.push(`- channel_name: #${channelName}`);
|
|
510
744
|
}
|
|
511
|
-
const channelTopic =
|
|
745
|
+
const channelTopic =
|
|
746
|
+
typeof namedChannel.topic === 'string' ? namedChannel.topic.trim() : '';
|
|
512
747
|
if (channelTopic) {
|
|
513
748
|
lines.push(`- channel_topic: ${channelTopic}`);
|
|
514
749
|
}
|
|
515
|
-
const parentName =
|
|
750
|
+
const parentName =
|
|
751
|
+
typeof namedChannel.parent?.name === 'string'
|
|
752
|
+
? namedChannel.parent.name.trim()
|
|
753
|
+
: '';
|
|
516
754
|
if (parentName) {
|
|
517
755
|
lines.push(`- parent_channel: ${parentName}`);
|
|
518
756
|
}
|
|
@@ -524,14 +762,21 @@ function buildChannelInfoContext(msg: DiscordMessage): string {
|
|
|
524
762
|
async function buildReplyContext(msg: DiscordMessage): Promise<string> {
|
|
525
763
|
const blocks: string[] = [];
|
|
526
764
|
|
|
527
|
-
if (
|
|
765
|
+
if (
|
|
766
|
+
'isThread' in msg.channel &&
|
|
767
|
+
typeof msg.channel.isThread === 'function' &&
|
|
768
|
+
msg.channel.isThread()
|
|
769
|
+
) {
|
|
528
770
|
try {
|
|
529
771
|
const starter = await msg.channel.fetchStarterMessage();
|
|
530
772
|
if (starter) {
|
|
531
773
|
blocks.push(`[Thread starter]\n${summarizeContextMessage(starter)}`);
|
|
532
774
|
}
|
|
533
775
|
} catch (error) {
|
|
534
|
-
logger.debug(
|
|
776
|
+
logger.debug(
|
|
777
|
+
{ error, channelId: msg.channelId },
|
|
778
|
+
'Failed to fetch thread starter message',
|
|
779
|
+
);
|
|
535
780
|
}
|
|
536
781
|
}
|
|
537
782
|
|
|
@@ -556,63 +801,18 @@ async function buildReplyContext(msg: DiscordMessage): Promise<string> {
|
|
|
556
801
|
return `${blocks.join('\n\n')}\n\n`;
|
|
557
802
|
}
|
|
558
803
|
|
|
559
|
-
async function addProcessingReaction(msg: DiscordMessage): Promise<() => Promise<void>> {
|
|
560
|
-
if (!client.user) return async () => {};
|
|
561
|
-
const botUserId = client.user.id;
|
|
562
|
-
try {
|
|
563
|
-
await withDiscordRetry('react', () => msg.react('👀'));
|
|
564
|
-
} catch (error) {
|
|
565
|
-
logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to add processing reaction');
|
|
566
|
-
return async () => {};
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
return async () => {
|
|
570
|
-
try {
|
|
571
|
-
const reaction = msg.reactions.resolve('👀');
|
|
572
|
-
if (!reaction) return;
|
|
573
|
-
await withDiscordRetry('reaction-remove', () => reaction.users.remove(botUserId));
|
|
574
|
-
} catch (error) {
|
|
575
|
-
logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to remove processing reaction');
|
|
576
|
-
}
|
|
577
|
-
};
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function startTypingLoop(msg: DiscordMessage): { stop: () => void } {
|
|
581
|
-
let stopped = false;
|
|
582
|
-
const sendTyping = async (): Promise<void> => {
|
|
583
|
-
if (stopped) return;
|
|
584
|
-
if (!('sendTyping' in msg.channel)) return;
|
|
585
|
-
try {
|
|
586
|
-
await msg.channel.sendTyping();
|
|
587
|
-
} catch (error) {
|
|
588
|
-
logger.debug({ error, channelId: msg.channelId }, 'Failed to send typing indicator');
|
|
589
|
-
}
|
|
590
|
-
};
|
|
591
|
-
|
|
592
|
-
void sendTyping();
|
|
593
|
-
const timer = setInterval(() => {
|
|
594
|
-
void sendTyping();
|
|
595
|
-
}, 8_000);
|
|
596
|
-
|
|
597
|
-
return {
|
|
598
|
-
stop: () => {
|
|
599
|
-
if (stopped) return;
|
|
600
|
-
stopped = true;
|
|
601
|
-
clearInterval(timer);
|
|
602
|
-
},
|
|
603
|
-
};
|
|
604
|
-
}
|
|
605
|
-
|
|
606
804
|
async function sendChunkedReply(
|
|
607
805
|
msg: DiscordMessage,
|
|
608
806
|
text: string,
|
|
609
807
|
files?: AttachmentBuilder[],
|
|
610
808
|
mentionLookup?: MentionLookup,
|
|
809
|
+
humanDelay?: HumanDelayConfig,
|
|
611
810
|
): Promise<void> {
|
|
612
811
|
await sendChunkedReplyFromDelivery({
|
|
613
812
|
msg,
|
|
614
813
|
text,
|
|
615
814
|
withRetry: withDiscordRetry,
|
|
815
|
+
...(humanDelay ? { humanDelay } : {}),
|
|
616
816
|
...(files ? { files } : {}),
|
|
617
817
|
...(mentionLookup ? { mentionLookup } : {}),
|
|
618
818
|
});
|
|
@@ -623,18 +823,22 @@ async function sendChunkedDirectReply(
|
|
|
623
823
|
text: string,
|
|
624
824
|
files?: AttachmentBuilder[],
|
|
625
825
|
mentionLookup?: MentionLookup,
|
|
826
|
+
humanDelay?: HumanDelayConfig,
|
|
626
827
|
): Promise<void> {
|
|
627
828
|
await sendChunkedDirectReplyFromDelivery({
|
|
628
829
|
msg,
|
|
629
830
|
text,
|
|
630
831
|
withRetry: withDiscordRetry,
|
|
832
|
+
...(humanDelay ? { humanDelay } : {}),
|
|
631
833
|
...(files ? { files } : {}),
|
|
632
834
|
...(mentionLookup ? { mentionLookup } : {}),
|
|
633
835
|
});
|
|
634
836
|
}
|
|
635
837
|
|
|
636
838
|
async function sendChunkedInteractionReply(
|
|
637
|
-
interaction: Parameters<
|
|
839
|
+
interaction: Parameters<
|
|
840
|
+
typeof sendChunkedInteractionReplyFromDelivery
|
|
841
|
+
>[0]['interaction'],
|
|
638
842
|
text: string,
|
|
639
843
|
files?: AttachmentBuilder[],
|
|
640
844
|
): Promise<void> {
|
|
@@ -646,74 +850,266 @@ async function sendChunkedInteractionReply(
|
|
|
646
850
|
});
|
|
647
851
|
}
|
|
648
852
|
|
|
649
|
-
async function
|
|
650
|
-
|
|
651
|
-
name:
|
|
652
|
-
description:
|
|
653
|
-
|
|
853
|
+
async function ensureSlashCommands(): Promise<void> {
|
|
854
|
+
interface SlashCommandDefinition {
|
|
855
|
+
name: string;
|
|
856
|
+
description: string;
|
|
857
|
+
options?: Array<{
|
|
858
|
+
type: ApplicationCommandOptionType.String;
|
|
859
|
+
name: string;
|
|
860
|
+
description: string;
|
|
861
|
+
required?: boolean;
|
|
862
|
+
choices?: Array<{ name: string; value: string }>;
|
|
863
|
+
}>;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const definitions: SlashCommandDefinition[] = [
|
|
867
|
+
{
|
|
868
|
+
name: 'status',
|
|
869
|
+
description: 'Show HybridClaw runtime status (only visible to you)',
|
|
870
|
+
},
|
|
871
|
+
{
|
|
872
|
+
name: 'channel-mode',
|
|
873
|
+
description: 'Set this channel to off, mention-only, or free-response',
|
|
874
|
+
options: [
|
|
875
|
+
{
|
|
876
|
+
type: ApplicationCommandOptionType.String,
|
|
877
|
+
name: 'mode',
|
|
878
|
+
description: 'Response mode for this channel',
|
|
879
|
+
required: true,
|
|
880
|
+
choices: [
|
|
881
|
+
{ name: 'off', value: 'off' },
|
|
882
|
+
{ name: 'mention', value: 'mention' },
|
|
883
|
+
{ name: 'free', value: 'free' },
|
|
884
|
+
],
|
|
885
|
+
},
|
|
886
|
+
],
|
|
887
|
+
},
|
|
888
|
+
{
|
|
889
|
+
name: 'channel-policy',
|
|
890
|
+
description: 'Set guild channel policy to open, allowlist, or disabled',
|
|
891
|
+
options: [
|
|
892
|
+
{
|
|
893
|
+
type: ApplicationCommandOptionType.String,
|
|
894
|
+
name: 'policy',
|
|
895
|
+
description: 'Guild channel policy',
|
|
896
|
+
required: true,
|
|
897
|
+
choices: [
|
|
898
|
+
{ name: 'open', value: 'open' },
|
|
899
|
+
{ name: 'allowlist', value: 'allowlist' },
|
|
900
|
+
{ name: 'disabled', value: 'disabled' },
|
|
901
|
+
],
|
|
902
|
+
},
|
|
903
|
+
],
|
|
904
|
+
},
|
|
905
|
+
];
|
|
654
906
|
|
|
655
907
|
if (!client.application) return;
|
|
656
908
|
await Promise.allSettled(
|
|
657
909
|
[...client.guilds.cache.values()].map(async (guild) => {
|
|
658
910
|
try {
|
|
659
911
|
const existing = await guild.commands.fetch();
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
912
|
+
for (const definition of definitions) {
|
|
913
|
+
const current = existing.find(
|
|
914
|
+
(command) => command.name === definition.name,
|
|
915
|
+
);
|
|
916
|
+
if (!current) {
|
|
917
|
+
await guild.commands.create(
|
|
918
|
+
definition as unknown as ApplicationCommandDataResolvable,
|
|
919
|
+
);
|
|
920
|
+
logger.info(
|
|
921
|
+
{ guildId: guild.id, command: definition.name },
|
|
922
|
+
'Registered slash command',
|
|
923
|
+
);
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
await guild.commands.edit(
|
|
927
|
+
current.id,
|
|
928
|
+
definition as unknown as ApplicationCommandDataResolvable,
|
|
929
|
+
);
|
|
930
|
+
logger.info(
|
|
931
|
+
{ guildId: guild.id, command: definition.name },
|
|
932
|
+
'Updated slash command',
|
|
933
|
+
);
|
|
669
934
|
}
|
|
670
935
|
} catch (error) {
|
|
671
|
-
logger.warn(
|
|
936
|
+
logger.warn(
|
|
937
|
+
{ error, guildId: guild.id },
|
|
938
|
+
'Failed to register Discord slash commands',
|
|
939
|
+
);
|
|
672
940
|
}
|
|
673
941
|
}),
|
|
674
942
|
);
|
|
675
943
|
}
|
|
676
944
|
|
|
677
|
-
function
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
945
|
+
function trimRecentConversationMetrics(nowMs = Date.now()): void {
|
|
946
|
+
const cutoff = nowMs - PRESENCE_WINDOW_MS;
|
|
947
|
+
while (
|
|
948
|
+
recentConversationMetrics.length > 0 &&
|
|
949
|
+
recentConversationMetrics[0].atMs < cutoff
|
|
950
|
+
) {
|
|
951
|
+
recentConversationMetrics.shift();
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function recordConversationMetric(params: {
|
|
956
|
+
durationMs: number;
|
|
957
|
+
ok: boolean;
|
|
958
|
+
error?: unknown;
|
|
959
|
+
}): void {
|
|
960
|
+
const nowMs = Date.now();
|
|
961
|
+
const errorText =
|
|
962
|
+
params.error instanceof Error
|
|
963
|
+
? params.error.message
|
|
964
|
+
: String(params.error || '');
|
|
965
|
+
const exhaustedHint =
|
|
966
|
+
!params.ok && PRESENCE_EXHAUSTED_ERROR_RE.test(errorText);
|
|
967
|
+
if (params.ok) {
|
|
968
|
+
consecutiveConversationFailures = 0;
|
|
969
|
+
} else {
|
|
970
|
+
consecutiveConversationFailures += 1;
|
|
971
|
+
}
|
|
972
|
+
recentConversationMetrics.push({
|
|
973
|
+
atMs: nowMs,
|
|
974
|
+
durationMs: Math.max(0, Math.floor(params.durationMs)),
|
|
975
|
+
ok: params.ok,
|
|
976
|
+
exhaustedHint,
|
|
977
|
+
});
|
|
978
|
+
trimRecentConversationMetrics(nowMs);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function resolvePresenceHealthState(): PresenceHealthState {
|
|
982
|
+
trimRecentConversationMetrics();
|
|
983
|
+
if (activeConversationRuns >= 4) return 'degraded';
|
|
984
|
+
if (consecutiveConversationFailures >= 3) return 'exhausted';
|
|
985
|
+
if (recentConversationMetrics.some((entry) => entry.exhaustedHint))
|
|
986
|
+
return 'exhausted';
|
|
987
|
+
|
|
988
|
+
if (recentConversationMetrics.length === 0) return 'healthy';
|
|
989
|
+
const totalDuration = recentConversationMetrics.reduce(
|
|
990
|
+
(sum, entry) => sum + entry.durationMs,
|
|
991
|
+
0,
|
|
992
|
+
);
|
|
993
|
+
const avgDuration = totalDuration / recentConversationMetrics.length;
|
|
994
|
+
const failureCount = recentConversationMetrics.filter(
|
|
995
|
+
(entry) => !entry.ok,
|
|
996
|
+
).length;
|
|
997
|
+
const errorRate = failureCount / recentConversationMetrics.length;
|
|
998
|
+
if (errorRate >= 0.25 || avgDuration >= PRESENCE_DEGRADED_DURATION_MS)
|
|
999
|
+
return 'degraded';
|
|
1000
|
+
return 'healthy';
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function randomIntInRange(minMs: number, maxMs: number): number {
|
|
1004
|
+
const lo = Math.floor(Math.max(0, minMs));
|
|
1005
|
+
const hi = Math.floor(Math.max(lo, maxMs));
|
|
1006
|
+
if (hi <= lo) return lo;
|
|
1007
|
+
return lo + Math.floor(Math.random() * (hi - lo + 1));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function isNightOrWeekend(now: Date): boolean {
|
|
1011
|
+
const day = now.getDay();
|
|
1012
|
+
const hour = now.getHours();
|
|
1013
|
+
const weekend = day === 0 || day === 6;
|
|
1014
|
+
const night = hour >= NIGHT_HOURS_START || hour < NIGHT_HOURS_END;
|
|
1015
|
+
return weekend || night;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function buildConversationCooldownKey(
|
|
1019
|
+
channelId: string,
|
|
1020
|
+
userId: string,
|
|
1021
|
+
): string {
|
|
1022
|
+
return `${channelId}:${userId}`;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function resolveHumanDelayWithBehavior(
|
|
1026
|
+
base: HumanDelayConfig,
|
|
1027
|
+
cooldownKey: string,
|
|
1028
|
+
): HumanDelayConfig {
|
|
1029
|
+
if (base.mode === 'off') return base;
|
|
1030
|
+
const now = new Date();
|
|
1031
|
+
let factor = isNightOrWeekend(now) ? 1.5 : 1;
|
|
1032
|
+
const record = conversationExchangeByKey.get(cooldownKey);
|
|
1033
|
+
if (record) {
|
|
1034
|
+
const elapsedMs = Date.now() - record.lastAtMs;
|
|
1035
|
+
if (elapsedMs > CONVERSATION_COOLDOWN_RESET_MS) {
|
|
1036
|
+
conversationExchangeByKey.delete(cooldownKey);
|
|
1037
|
+
} else if (record.count > CONVERSATION_COOLDOWN_THRESHOLD) {
|
|
1038
|
+
const extra = Math.min(
|
|
1039
|
+
CONVERSATION_COOLDOWN_MAX_FACTOR - 1,
|
|
1040
|
+
(record.count - CONVERSATION_COOLDOWN_THRESHOLD) * 0.12,
|
|
1041
|
+
);
|
|
1042
|
+
factor += Math.max(0, extra);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (factor <= 1) return base;
|
|
1047
|
+
const minMs = Math.round((base.minMs ?? 800) * factor);
|
|
1048
|
+
const maxMs = Math.round((base.maxMs ?? 2_500) * factor);
|
|
1049
|
+
return {
|
|
1050
|
+
mode: 'custom',
|
|
1051
|
+
minMs,
|
|
1052
|
+
maxMs: Math.max(minMs, maxMs),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function noteConversationExchange(cooldownKey: string): void {
|
|
1057
|
+
const nowMs = Date.now();
|
|
1058
|
+
const existing = conversationExchangeByKey.get(cooldownKey);
|
|
1059
|
+
if (!existing || nowMs - existing.lastAtMs > CONVERSATION_COOLDOWN_RESET_MS) {
|
|
1060
|
+
conversationExchangeByKey.set(cooldownKey, { count: 1, lastAtMs: nowMs });
|
|
684
1061
|
return;
|
|
685
1062
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1063
|
+
conversationExchangeByKey.set(cooldownKey, {
|
|
1064
|
+
count: existing.count + 1,
|
|
1065
|
+
lastAtMs: nowMs,
|
|
689
1066
|
});
|
|
690
1067
|
}
|
|
691
1068
|
|
|
692
|
-
|
|
1069
|
+
function pickReadWithoutReplyEmoji(): string {
|
|
1070
|
+
return Math.random() < 0.7 ? '👍' : '✅';
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
export function initDiscord(
|
|
1074
|
+
onMessage: MessageHandler,
|
|
1075
|
+
onCommand: CommandHandler,
|
|
1076
|
+
): Client {
|
|
693
1077
|
messageHandler = onMessage;
|
|
694
1078
|
commandHandler = onCommand;
|
|
695
1079
|
|
|
696
1080
|
interface QueuedConversationMessage {
|
|
697
1081
|
msg: DiscordMessage;
|
|
698
1082
|
content: string;
|
|
699
|
-
|
|
1083
|
+
behavior: ResolvedChannelBehavior;
|
|
1084
|
+
clearAckReaction: () => Promise<void>;
|
|
1085
|
+
wasExplicitlyAddressed: boolean;
|
|
1086
|
+
cooldownKey: string;
|
|
700
1087
|
}
|
|
701
1088
|
interface PendingConversationBatch {
|
|
702
1089
|
items: QueuedConversationMessage[];
|
|
703
1090
|
timer: ReturnType<typeof setTimeout>;
|
|
1091
|
+
typingController: ReturnType<typeof createTypingController>;
|
|
1092
|
+
lifecycleController: LifecycleReactionController | null;
|
|
704
1093
|
}
|
|
705
1094
|
interface InFlightConversation {
|
|
706
1095
|
abortController: AbortController;
|
|
707
1096
|
stream: DiscordStreamManager;
|
|
708
1097
|
messageIds: Set<string>;
|
|
709
1098
|
aborted: boolean;
|
|
1099
|
+
emitLifecyclePhase: (phase: LifecyclePhase) => void;
|
|
710
1100
|
}
|
|
711
1101
|
const pendingBatches = new Map<string, PendingConversationBatch>();
|
|
712
1102
|
const inFlightByMessageId = new Map<string, InFlightConversation>();
|
|
1103
|
+
const channelConcurrencyById = new Map<string, number>();
|
|
713
1104
|
const negativeFeedbackByChannel = new Map<string, string>();
|
|
714
|
-
const participantMemoryByChannel = new Map<
|
|
715
|
-
|
|
716
|
-
|
|
1105
|
+
const participantMemoryByChannel = new Map<
|
|
1106
|
+
string,
|
|
1107
|
+
Map<string, Set<string>>
|
|
1108
|
+
>();
|
|
1109
|
+
|
|
1110
|
+
const touchParticipantMemoryChannel = (
|
|
1111
|
+
channelId: string,
|
|
1112
|
+
): Map<string, Set<string>> => {
|
|
717
1113
|
const existing = participantMemoryByChannel.get(channelId);
|
|
718
1114
|
if (existing) {
|
|
719
1115
|
participantMemoryByChannel.delete(channelId);
|
|
@@ -730,7 +1126,11 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
730
1126
|
return created;
|
|
731
1127
|
};
|
|
732
1128
|
|
|
733
|
-
const rememberParticipantAliasForChannel = (
|
|
1129
|
+
const rememberParticipantAliasForChannel = (
|
|
1130
|
+
channelId: string,
|
|
1131
|
+
userId: string,
|
|
1132
|
+
rawAlias: string | null | undefined,
|
|
1133
|
+
): void => {
|
|
734
1134
|
if (!userId || userId === client.user?.id) return;
|
|
735
1135
|
const alias = normalizeMentionAlias(rawAlias);
|
|
736
1136
|
if (!alias) return;
|
|
@@ -747,7 +1147,9 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
747
1147
|
}
|
|
748
1148
|
aliases.add(alias);
|
|
749
1149
|
if (aliases.size > PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER) {
|
|
750
|
-
const kept = new Set(
|
|
1150
|
+
const kept = new Set(
|
|
1151
|
+
Array.from(aliases).slice(-PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER),
|
|
1152
|
+
);
|
|
751
1153
|
channelMemory.set(userId, kept);
|
|
752
1154
|
}
|
|
753
1155
|
// Refresh user recency.
|
|
@@ -758,14 +1160,21 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
758
1160
|
}
|
|
759
1161
|
};
|
|
760
1162
|
|
|
761
|
-
const rememberParticipantForChannel = (
|
|
1163
|
+
const rememberParticipantForChannel = (
|
|
1164
|
+
channelId: string,
|
|
1165
|
+
userId: string,
|
|
1166
|
+
aliases: Array<string | null | undefined>,
|
|
1167
|
+
): void => {
|
|
762
1168
|
if (!userId || userId === client.user?.id) return;
|
|
763
1169
|
for (const alias of aliases) {
|
|
764
1170
|
rememberParticipantAliasForChannel(channelId, userId, alias);
|
|
765
1171
|
}
|
|
766
1172
|
};
|
|
767
1173
|
|
|
768
|
-
const observeMessageParticipants = (
|
|
1174
|
+
const observeMessageParticipants = (
|
|
1175
|
+
msg: DiscordMessage,
|
|
1176
|
+
content: string,
|
|
1177
|
+
): void => {
|
|
769
1178
|
if (!msg.guild) return;
|
|
770
1179
|
rememberParticipantForChannel(msg.channelId, msg.author.id, [
|
|
771
1180
|
msg.author.username,
|
|
@@ -779,10 +1188,140 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
779
1188
|
]);
|
|
780
1189
|
}
|
|
781
1190
|
for (const hint of extractMentionAliasHints(content)) {
|
|
782
|
-
rememberParticipantAliasForChannel(
|
|
1191
|
+
rememberParticipantAliasForChannel(
|
|
1192
|
+
msg.channelId,
|
|
1193
|
+
hint.userId,
|
|
1194
|
+
hint.alias,
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
const waitForChannelConcurrencySlot = async (
|
|
1200
|
+
channelId: string,
|
|
1201
|
+
maxConcurrent: number,
|
|
1202
|
+
abortSignal?: AbortSignal,
|
|
1203
|
+
): Promise<() => void> => {
|
|
1204
|
+
const boundedMax = Math.max(1, Math.floor(maxConcurrent));
|
|
1205
|
+
while ((channelConcurrencyById.get(channelId) ?? 0) >= boundedMax) {
|
|
1206
|
+
if (abortSignal?.aborted) {
|
|
1207
|
+
throw new Error(
|
|
1208
|
+
'Conversation aborted while waiting for channel concurrency slot.',
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
await new Promise((resolve) =>
|
|
1212
|
+
setTimeout(resolve, CONCURRENCY_RETRY_DELAY_MS),
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
if (abortSignal?.aborted) {
|
|
1216
|
+
throw new Error(
|
|
1217
|
+
'Conversation aborted before acquiring channel concurrency slot.',
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
channelConcurrencyById.set(
|
|
1221
|
+
channelId,
|
|
1222
|
+
(channelConcurrencyById.get(channelId) ?? 0) + 1,
|
|
1223
|
+
);
|
|
1224
|
+
return () => {
|
|
1225
|
+
const current = channelConcurrencyById.get(channelId) ?? 0;
|
|
1226
|
+
const next = Math.max(0, current - 1);
|
|
1227
|
+
if (next === 0) {
|
|
1228
|
+
channelConcurrencyById.delete(channelId);
|
|
1229
|
+
} else {
|
|
1230
|
+
channelConcurrencyById.set(channelId, next);
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
const enforcePerUserRateLimit = async (
|
|
1236
|
+
msg: DiscordMessage,
|
|
1237
|
+
behavior: ResolvedChannelBehavior,
|
|
1238
|
+
): Promise<boolean> => {
|
|
1239
|
+
const limit = Math.max(0, Math.floor(behavior.rateLimitPerUser));
|
|
1240
|
+
if (limit === 0) return true;
|
|
1241
|
+
if (isRateLimitExempt(msg)) return true;
|
|
1242
|
+
|
|
1243
|
+
const key = `${msg.channelId}:${msg.author.id}`;
|
|
1244
|
+
const decision = userRateLimiter.check(key, limit);
|
|
1245
|
+
if (decision.allowed) return true;
|
|
1246
|
+
|
|
1247
|
+
if (userRateLimiter.shouldNotify(key, RATE_LIMIT_NOTIFY_COOLDOWN_MS)) {
|
|
1248
|
+
try {
|
|
1249
|
+
await withDiscordRetry('rate-limit-reply', () =>
|
|
1250
|
+
msg.reply({ content: FRIENDLY_RATE_LIMIT_MESSAGE }),
|
|
1251
|
+
);
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
logger.debug(
|
|
1254
|
+
{ error, channelId: msg.channelId, userId: msg.author.id },
|
|
1255
|
+
'Failed to send rate-limit warning',
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return false;
|
|
1260
|
+
};
|
|
1261
|
+
|
|
1262
|
+
const maybeHandleReadWithoutReply = async (
|
|
1263
|
+
msg: DiscordMessage,
|
|
1264
|
+
content: string,
|
|
1265
|
+
): Promise<boolean> => {
|
|
1266
|
+
if (!content.trim()) return false;
|
|
1267
|
+
if (msg.attachments.size > 0) return false;
|
|
1268
|
+
if (hasPrefixInvocation(msg.content || '')) return false;
|
|
1269
|
+
if (client.user && msg.mentions.has(client.user)) return false;
|
|
1270
|
+
if (content.trim().length > 80) return false;
|
|
1271
|
+
if (!READ_WITHOUT_REPLY_RE.test(content.trim())) return false;
|
|
1272
|
+
if (Math.random() > READ_WITHOUT_REPLY_PROBABILITY) return false;
|
|
1273
|
+
|
|
1274
|
+
try {
|
|
1275
|
+
await withDiscordRetry('reaction-read-without-reply', () =>
|
|
1276
|
+
msg.react(pickReadWithoutReplyEmoji()),
|
|
1277
|
+
);
|
|
1278
|
+
return true;
|
|
1279
|
+
} catch (error) {
|
|
1280
|
+
logger.debug(
|
|
1281
|
+
{ error, channelId: msg.channelId, messageId: msg.id },
|
|
1282
|
+
'Failed read-without-reply reaction',
|
|
1283
|
+
);
|
|
1284
|
+
return false;
|
|
783
1285
|
}
|
|
784
1286
|
};
|
|
785
1287
|
|
|
1288
|
+
const shouldSelectivelySilence = (params: {
|
|
1289
|
+
sourceItem: QueuedConversationMessage;
|
|
1290
|
+
inboundHistory: PendingGuildHistoryEntry[];
|
|
1291
|
+
behavior: ResolvedChannelBehavior;
|
|
1292
|
+
}): boolean => {
|
|
1293
|
+
if (!params.sourceItem.msg.guild) return false;
|
|
1294
|
+
if (params.behavior.guildMessageMode !== 'free') return false;
|
|
1295
|
+
if (params.sourceItem.wasExplicitlyAddressed) return false;
|
|
1296
|
+
|
|
1297
|
+
const nowMs = Date.now();
|
|
1298
|
+
const peerMessages = params.inboundHistory
|
|
1299
|
+
.filter(
|
|
1300
|
+
(entry) =>
|
|
1301
|
+
!entry.isBot &&
|
|
1302
|
+
entry.userId !== params.sourceItem.msg.author.id &&
|
|
1303
|
+
nowMs - entry.timestampMs <= SELECTIVE_SILENCE_RECENT_WINDOW_MS,
|
|
1304
|
+
)
|
|
1305
|
+
.sort((a, b) => a.timestampMs - b.timestampMs);
|
|
1306
|
+
if (peerMessages.length === 0) return false;
|
|
1307
|
+
|
|
1308
|
+
const sourceText = params.sourceItem.content.trim();
|
|
1309
|
+
const asksQuestion = sourceText.includes('?');
|
|
1310
|
+
const latestPeer =
|
|
1311
|
+
peerMessages[peerMessages.length - 1]?.content?.trim().toLowerCase() ||
|
|
1312
|
+
'';
|
|
1313
|
+
const peerLooksLikeAnswer =
|
|
1314
|
+
latestPeer.length >= 24 ||
|
|
1315
|
+
/\\b(you can|try|use|it is|it's|because|should|here|answer|fix)\\b/.test(
|
|
1316
|
+
latestPeer,
|
|
1317
|
+
);
|
|
1318
|
+
const probability =
|
|
1319
|
+
asksQuestion && peerLooksLikeAnswer
|
|
1320
|
+
? SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY
|
|
1321
|
+
: SELECTIVE_SILENCE_BASE_PROBABILITY;
|
|
1322
|
+
return Math.random() < probability;
|
|
1323
|
+
};
|
|
1324
|
+
|
|
786
1325
|
const intents: GatewayIntentBits[] = [
|
|
787
1326
|
GatewayIntentBits.Guilds,
|
|
788
1327
|
GatewayIntentBits.GuildMessages,
|
|
@@ -790,12 +1329,18 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
790
1329
|
GatewayIntentBits.MessageContent,
|
|
791
1330
|
GatewayIntentBits.DirectMessages,
|
|
792
1331
|
];
|
|
793
|
-
if (DISCORD_GUILD_MEMBERS_INTENT)
|
|
1332
|
+
if (DISCORD_GUILD_MEMBERS_INTENT)
|
|
1333
|
+
intents.push(GatewayIntentBits.GuildMembers);
|
|
794
1334
|
if (DISCORD_PRESENCE_INTENT) intents.push(GatewayIntentBits.GuildPresences);
|
|
795
1335
|
|
|
796
1336
|
client = new Client({
|
|
797
1337
|
intents,
|
|
798
|
-
partials: [
|
|
1338
|
+
partials: [
|
|
1339
|
+
Partials.Channel,
|
|
1340
|
+
Partials.Message,
|
|
1341
|
+
Partials.Reaction,
|
|
1342
|
+
Partials.User,
|
|
1343
|
+
],
|
|
799
1344
|
});
|
|
800
1345
|
|
|
801
1346
|
client.on('presenceUpdate', (_oldPresence, nextPresence) => {
|
|
@@ -814,16 +1359,29 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
814
1359
|
|
|
815
1360
|
client.on('clientReady', () => {
|
|
816
1361
|
logger.info({ user: client.user?.tag }, 'Discord bot connected');
|
|
1362
|
+
startupConnectedAtMs = Date.now();
|
|
817
1363
|
if (client.user) {
|
|
818
1364
|
botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
|
|
819
1365
|
}
|
|
820
|
-
|
|
821
|
-
|
|
1366
|
+
presenceController?.stop();
|
|
1367
|
+
presenceController = new DiscordAutoPresenceController({
|
|
1368
|
+
client,
|
|
1369
|
+
getConfig: () => DISCORD_SELF_PRESENCE,
|
|
1370
|
+
resolveState: resolvePresenceHealthState,
|
|
1371
|
+
});
|
|
1372
|
+
presenceController.start();
|
|
1373
|
+
void ensureSlashCommands();
|
|
822
1374
|
});
|
|
823
1375
|
|
|
824
1376
|
client.on('interactionCreate', async (interaction) => {
|
|
825
1377
|
if (!interaction.isChatInputCommand()) return;
|
|
826
|
-
if (
|
|
1378
|
+
if (
|
|
1379
|
+
interaction.commandName !== 'status' &&
|
|
1380
|
+
interaction.commandName !== 'channel-mode' &&
|
|
1381
|
+
interaction.commandName !== 'channel-policy'
|
|
1382
|
+
) {
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
827
1385
|
|
|
828
1386
|
if (!isAuthorizedCommandUserId(interaction.user.id)) {
|
|
829
1387
|
await sendChunkedInteractionReply(
|
|
@@ -835,22 +1393,69 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
835
1393
|
|
|
836
1394
|
const guildId = interaction.guildId ?? null;
|
|
837
1395
|
const channelId = interaction.channelId;
|
|
838
|
-
const sessionId = buildSessionIdFromContext(
|
|
1396
|
+
const sessionId = buildSessionIdFromContext(
|
|
1397
|
+
guildId,
|
|
1398
|
+
channelId,
|
|
1399
|
+
interaction.user.id,
|
|
1400
|
+
);
|
|
1401
|
+
const args =
|
|
1402
|
+
interaction.commandName === 'status'
|
|
1403
|
+
? ['status']
|
|
1404
|
+
: interaction.commandName === 'channel-mode'
|
|
1405
|
+
? (() => {
|
|
1406
|
+
if (!interaction.guildId) return null;
|
|
1407
|
+
const selectedMode = interaction.options
|
|
1408
|
+
.getString('mode', true)
|
|
1409
|
+
.trim()
|
|
1410
|
+
.toLowerCase();
|
|
1411
|
+
if (
|
|
1412
|
+
selectedMode !== 'off' &&
|
|
1413
|
+
selectedMode !== 'mention' &&
|
|
1414
|
+
selectedMode !== 'free'
|
|
1415
|
+
)
|
|
1416
|
+
return null;
|
|
1417
|
+
return ['channel', 'mode', selectedMode];
|
|
1418
|
+
})()
|
|
1419
|
+
: (() => {
|
|
1420
|
+
if (!interaction.guildId) return null;
|
|
1421
|
+
const selectedPolicy = interaction.options
|
|
1422
|
+
.getString('policy', true)
|
|
1423
|
+
.trim()
|
|
1424
|
+
.toLowerCase();
|
|
1425
|
+
if (
|
|
1426
|
+
selectedPolicy !== 'open' &&
|
|
1427
|
+
selectedPolicy !== 'allowlist' &&
|
|
1428
|
+
selectedPolicy !== 'disabled'
|
|
1429
|
+
)
|
|
1430
|
+
return null;
|
|
1431
|
+
return ['channel', 'policy', selectedPolicy];
|
|
1432
|
+
})();
|
|
1433
|
+
if (!args) {
|
|
1434
|
+
await sendChunkedInteractionReply(
|
|
1435
|
+
interaction,
|
|
1436
|
+
'This command can only be used in a server channel with a valid mode/policy option.',
|
|
1437
|
+
);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
839
1440
|
try {
|
|
840
1441
|
await commandHandler(
|
|
841
1442
|
sessionId,
|
|
842
1443
|
guildId,
|
|
843
1444
|
channelId,
|
|
844
|
-
|
|
845
|
-
async (text, files) =>
|
|
1445
|
+
args,
|
|
1446
|
+
async (text, files) =>
|
|
1447
|
+
sendChunkedInteractionReply(interaction, text, files),
|
|
846
1448
|
);
|
|
847
1449
|
} catch (error) {
|
|
848
1450
|
const detail = error instanceof Error ? error.message : String(error);
|
|
849
1451
|
logger.error(
|
|
850
1452
|
{ error, guildId, channelId, userId: interaction.user.id },
|
|
851
|
-
'Discord slash
|
|
1453
|
+
'Discord slash command failed',
|
|
1454
|
+
);
|
|
1455
|
+
await sendChunkedInteractionReply(
|
|
1456
|
+
interaction,
|
|
1457
|
+
formatError('Gateway Error', detail),
|
|
852
1458
|
);
|
|
853
|
-
await sendChunkedInteractionReply(interaction, formatError('Gateway Error', detail));
|
|
854
1459
|
}
|
|
855
1460
|
});
|
|
856
1461
|
|
|
@@ -868,10 +1473,16 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
868
1473
|
const channelId = msg.channelId;
|
|
869
1474
|
const userId = msg.author.id;
|
|
870
1475
|
const username = msg.author.username;
|
|
871
|
-
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
|
|
1476
|
+
const behavior = sourceItem.behavior;
|
|
1477
|
+
const startedAt = Date.now();
|
|
1478
|
+
let releaseChannelSlot: (() => void) | null = null;
|
|
1479
|
+
|
|
1480
|
+
const batchedContent =
|
|
1481
|
+
items.length > 1
|
|
1482
|
+
? items
|
|
1483
|
+
.map((item, index) => `Message ${index + 1}:\n${item.content}`)
|
|
1484
|
+
.join('\n\n')
|
|
1485
|
+
: sourceItem.content;
|
|
875
1486
|
const channelInfoContext = buildChannelInfoContext(msg);
|
|
876
1487
|
const replyContext = await buildReplyContext(msg);
|
|
877
1488
|
const feedbackNote = negativeFeedbackByChannel.get(channelId) || '';
|
|
@@ -879,9 +1490,16 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
879
1490
|
negativeFeedbackByChannel.delete(channelId);
|
|
880
1491
|
}
|
|
881
1492
|
const currentBatchMessageIds = new Set(items.map((item) => item.msg.id));
|
|
882
|
-
const inboundHistory = await buildInboundHistorySnapshot(
|
|
883
|
-
|
|
884
|
-
|
|
1493
|
+
const inboundHistory = await buildInboundHistorySnapshot(
|
|
1494
|
+
msg,
|
|
1495
|
+
currentBatchMessageIds,
|
|
1496
|
+
);
|
|
1497
|
+
const attachmentContext = await buildAttachmentContext(
|
|
1498
|
+
items.map((item) => item.msg),
|
|
1499
|
+
);
|
|
1500
|
+
const rememberedParticipants = participantMemoryByChannel.get(
|
|
1501
|
+
msg.channelId,
|
|
1502
|
+
);
|
|
885
1503
|
const participantContext = buildParticipantContext(
|
|
886
1504
|
items.map((item) => item.msg),
|
|
887
1505
|
inboundHistory.entries,
|
|
@@ -893,25 +1511,70 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
893
1511
|
rememberedParticipants,
|
|
894
1512
|
);
|
|
895
1513
|
const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext.context}${participantContext}${batchedContent}`;
|
|
1514
|
+
const selectiveSilence = shouldSelectivelySilence({
|
|
1515
|
+
sourceItem,
|
|
1516
|
+
inboundHistory: inboundHistory.entries,
|
|
1517
|
+
behavior,
|
|
1518
|
+
});
|
|
896
1519
|
|
|
897
1520
|
const abortController = new AbortController();
|
|
898
|
-
const
|
|
1521
|
+
const typingController = pending.typingController;
|
|
1522
|
+
const lifecycleController = pending.lifecycleController;
|
|
1523
|
+
const emitLifecyclePhase = (phase: LifecyclePhase): void => {
|
|
1524
|
+
if (phase === 'queued') {
|
|
1525
|
+
typingController.setPhase('received');
|
|
1526
|
+
} else if (phase === 'thinking') {
|
|
1527
|
+
typingController.setPhase('thinking');
|
|
1528
|
+
} else if (phase === 'toolUse') {
|
|
1529
|
+
typingController.setPhase('toolUse');
|
|
1530
|
+
} else if (phase === 'streaming') {
|
|
1531
|
+
typingController.setPhase('streaming');
|
|
1532
|
+
} else {
|
|
1533
|
+
typingController.setPhase('done');
|
|
1534
|
+
}
|
|
1535
|
+
lifecycleController?.setPhase(phase);
|
|
1536
|
+
};
|
|
1537
|
+
|
|
899
1538
|
const stream = new DiscordStreamManager(msg, {
|
|
900
|
-
onFirstMessage: () =>
|
|
1539
|
+
onFirstMessage: () => emitLifecyclePhase('streaming'),
|
|
1540
|
+
humanDelay: behavior.humanDelay,
|
|
901
1541
|
});
|
|
902
1542
|
const inFlight: InFlightConversation = {
|
|
903
1543
|
abortController,
|
|
904
1544
|
stream,
|
|
905
1545
|
messageIds: new Set(items.map((item) => item.msg.id)),
|
|
906
1546
|
aborted: false,
|
|
1547
|
+
emitLifecyclePhase,
|
|
907
1548
|
};
|
|
908
1549
|
for (const messageId of inFlight.messageIds) {
|
|
909
1550
|
inFlightByMessageId.set(messageId, inFlight);
|
|
910
1551
|
}
|
|
911
1552
|
|
|
912
1553
|
try {
|
|
1554
|
+
if (selectiveSilence) {
|
|
1555
|
+
emitLifecyclePhase('done');
|
|
1556
|
+
if (Math.random() < 0.5) {
|
|
1557
|
+
await withDiscordRetry('reaction-selective-silence', () =>
|
|
1558
|
+
msg.react(pickReadWithoutReplyEmoji()),
|
|
1559
|
+
).catch(() => {});
|
|
1560
|
+
}
|
|
1561
|
+
recordConversationMetric({
|
|
1562
|
+
durationMs: Date.now() - startedAt,
|
|
1563
|
+
ok: true,
|
|
1564
|
+
});
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
releaseChannelSlot = await waitForChannelConcurrencySlot(
|
|
1569
|
+
channelId,
|
|
1570
|
+
behavior.maxConcurrentPerChannel,
|
|
1571
|
+
abortController.signal,
|
|
1572
|
+
);
|
|
1573
|
+
if (abortController.signal.aborted) {
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
913
1576
|
activeConversationRuns += 1;
|
|
914
|
-
|
|
1577
|
+
emitLifecyclePhase('thinking');
|
|
915
1578
|
await messageHandler(
|
|
916
1579
|
sessionId,
|
|
917
1580
|
guildId,
|
|
@@ -921,8 +1584,14 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
921
1584
|
combinedContent,
|
|
922
1585
|
attachmentContext.media,
|
|
923
1586
|
async (text, files) => {
|
|
924
|
-
|
|
925
|
-
await sendChunkedReply(
|
|
1587
|
+
emitLifecyclePhase('streaming');
|
|
1588
|
+
await sendChunkedReply(
|
|
1589
|
+
msg,
|
|
1590
|
+
text,
|
|
1591
|
+
files,
|
|
1592
|
+
mentionLookup,
|
|
1593
|
+
behavior.humanDelay,
|
|
1594
|
+
);
|
|
926
1595
|
},
|
|
927
1596
|
{
|
|
928
1597
|
sourceMessage: msg,
|
|
@@ -930,66 +1599,189 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
930
1599
|
abortSignal: abortController.signal,
|
|
931
1600
|
stream,
|
|
932
1601
|
mentionLookup,
|
|
1602
|
+
emitLifecyclePhase,
|
|
933
1603
|
},
|
|
934
1604
|
);
|
|
1605
|
+
emitLifecyclePhase('done');
|
|
1606
|
+
recordConversationMetric({
|
|
1607
|
+
durationMs: Date.now() - startedAt,
|
|
1608
|
+
ok: true,
|
|
1609
|
+
});
|
|
1610
|
+
noteConversationExchange(sourceItem.cooldownKey);
|
|
935
1611
|
} catch (error) {
|
|
936
|
-
|
|
1612
|
+
if (abortController.signal.aborted || inFlight.aborted) {
|
|
1613
|
+
logger.debug(
|
|
1614
|
+
{ channelId, sessionId },
|
|
1615
|
+
'Conversation batch aborted before completion',
|
|
1616
|
+
);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
emitLifecyclePhase('error');
|
|
1620
|
+
recordConversationMetric({
|
|
1621
|
+
durationMs: Date.now() - startedAt,
|
|
1622
|
+
ok: false,
|
|
1623
|
+
error,
|
|
1624
|
+
});
|
|
1625
|
+
logger.error(
|
|
1626
|
+
{ error, channelId, sessionId },
|
|
1627
|
+
'Conversation batch handling failed',
|
|
1628
|
+
);
|
|
937
1629
|
const detail = error instanceof Error ? error.message : String(error);
|
|
938
1630
|
if (stream.hasSentMessages()) {
|
|
939
1631
|
await stream.fail(formatError('Gateway Error', detail));
|
|
940
1632
|
} else {
|
|
941
|
-
await sendChunkedReply(
|
|
1633
|
+
await sendChunkedReply(
|
|
1634
|
+
msg,
|
|
1635
|
+
formatError('Gateway Error', detail),
|
|
1636
|
+
undefined,
|
|
1637
|
+
mentionLookup,
|
|
1638
|
+
behavior.humanDelay,
|
|
1639
|
+
);
|
|
942
1640
|
}
|
|
943
1641
|
} finally {
|
|
944
1642
|
activeConversationRuns = Math.max(0, activeConversationRuns - 1);
|
|
945
|
-
|
|
1643
|
+
if (releaseChannelSlot) {
|
|
1644
|
+
releaseChannelSlot();
|
|
1645
|
+
}
|
|
946
1646
|
for (const messageId of inFlight.messageIds) {
|
|
947
1647
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
948
1648
|
inFlightByMessageId.delete(messageId);
|
|
949
1649
|
}
|
|
950
1650
|
}
|
|
951
|
-
|
|
952
|
-
await Promise.all(
|
|
953
|
-
|
|
954
|
-
|
|
1651
|
+
typingController.stop();
|
|
1652
|
+
await Promise.all(
|
|
1653
|
+
items.map(async (item) => {
|
|
1654
|
+
await item.clearAckReaction();
|
|
1655
|
+
}),
|
|
1656
|
+
);
|
|
955
1657
|
}
|
|
956
1658
|
};
|
|
957
1659
|
|
|
958
1660
|
const queueConversationMessage = async (
|
|
959
1661
|
msg: DiscordMessage,
|
|
960
1662
|
content: string,
|
|
1663
|
+
behavior: ResolvedChannelBehavior,
|
|
961
1664
|
): Promise<void> => {
|
|
962
1665
|
const key = `${msg.channelId}:${msg.author.id}`;
|
|
963
|
-
const
|
|
964
|
-
|
|
1666
|
+
const cooldownKey = buildConversationCooldownKey(
|
|
1667
|
+
msg.channelId,
|
|
1668
|
+
msg.author.id,
|
|
1669
|
+
);
|
|
1670
|
+
const adjustedHumanDelay = resolveHumanDelayWithBehavior(
|
|
1671
|
+
behavior.humanDelay,
|
|
1672
|
+
cooldownKey,
|
|
1673
|
+
);
|
|
1674
|
+
const queuedBehavior: ResolvedChannelBehavior = {
|
|
1675
|
+
...behavior,
|
|
1676
|
+
humanDelay: adjustedHumanDelay,
|
|
1677
|
+
};
|
|
1678
|
+
const wasExplicitlyAddressed =
|
|
1679
|
+
!msg.guild ||
|
|
1680
|
+
hasPrefixInvocation(msg.content || '') ||
|
|
1681
|
+
Boolean(client.user && msg.mentions.has(client.user));
|
|
1682
|
+
|
|
1683
|
+
let clearAckReaction: () => Promise<void> = async () => {};
|
|
1684
|
+
if (client.user && shouldApplyAckReaction(msg, behavior)) {
|
|
1685
|
+
const clearReaction = await addAckReaction({
|
|
1686
|
+
message: msg,
|
|
1687
|
+
emoji: behavior.ackReaction,
|
|
1688
|
+
withRetry: withDiscordRetry,
|
|
1689
|
+
botUserId: client.user.id,
|
|
1690
|
+
});
|
|
1691
|
+
clearAckReaction = behavior.removeAckAfterReply
|
|
1692
|
+
? clearReaction
|
|
1693
|
+
: async () => {};
|
|
1694
|
+
}
|
|
1695
|
+
const queued: QueuedConversationMessage = {
|
|
1696
|
+
msg,
|
|
1697
|
+
content,
|
|
1698
|
+
behavior: queuedBehavior,
|
|
1699
|
+
clearAckReaction,
|
|
1700
|
+
wasExplicitlyAddressed,
|
|
1701
|
+
cooldownKey,
|
|
1702
|
+
};
|
|
965
1703
|
const existing = pendingBatches.get(key);
|
|
1704
|
+
const shouldDebounceMessage = shouldDebounceInbound({
|
|
1705
|
+
content: msg.content || '',
|
|
1706
|
+
hasAttachments: msg.attachments.size > 0,
|
|
1707
|
+
isPrefixedCommand: hasPrefixInvocation(msg.content || ''),
|
|
1708
|
+
});
|
|
966
1709
|
|
|
967
1710
|
if (!existing) {
|
|
1711
|
+
const typingController = createTypingController(msg, behavior.typingMode);
|
|
1712
|
+
typingController.setPhase('received');
|
|
1713
|
+
const lifecycleController =
|
|
1714
|
+
client.user && DISCORD_LIFECYCLE_REACTIONS.enabled
|
|
1715
|
+
? new LifecycleReactionController({
|
|
1716
|
+
message: msg,
|
|
1717
|
+
withRetry: withDiscordRetry,
|
|
1718
|
+
botUserId: client.user.id,
|
|
1719
|
+
config: {
|
|
1720
|
+
enabled: DISCORD_LIFECYCLE_REACTIONS.enabled,
|
|
1721
|
+
removeOnComplete: DISCORD_LIFECYCLE_REACTIONS.removeOnComplete,
|
|
1722
|
+
phases: DISCORD_LIFECYCLE_REACTIONS.phases,
|
|
1723
|
+
},
|
|
1724
|
+
})
|
|
1725
|
+
: null;
|
|
1726
|
+
lifecycleController?.setPhase('queued');
|
|
1727
|
+
const baseDelayMs = shouldDebounceMessage ? behavior.debounceMs : 0;
|
|
1728
|
+
const startupStaggerMs =
|
|
1729
|
+
startupConnectedAtMs > 0 &&
|
|
1730
|
+
Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
|
|
1731
|
+
!wasExplicitlyAddressed
|
|
1732
|
+
? randomIntInRange(
|
|
1733
|
+
STARTUP_STAGGER_MIN_DELAY_MS,
|
|
1734
|
+
STARTUP_STAGGER_MAX_DELAY_MS,
|
|
1735
|
+
)
|
|
1736
|
+
: 0;
|
|
1737
|
+
const delayMs = baseDelayMs + startupStaggerMs;
|
|
968
1738
|
const timer = setTimeout(() => {
|
|
969
1739
|
void dispatchConversationBatch(key);
|
|
970
|
-
},
|
|
1740
|
+
}, delayMs);
|
|
971
1741
|
pendingBatches.set(key, {
|
|
972
1742
|
items: [queued],
|
|
973
1743
|
timer,
|
|
1744
|
+
typingController,
|
|
1745
|
+
lifecycleController,
|
|
974
1746
|
});
|
|
975
1747
|
return;
|
|
976
1748
|
}
|
|
977
1749
|
|
|
1750
|
+
existing.typingController.setPhase('received');
|
|
1751
|
+
existing.lifecycleController?.setPhase('queued');
|
|
978
1752
|
clearTimeout(existing.timer);
|
|
979
1753
|
existing.items.push(queued);
|
|
1754
|
+
const shouldFlushImmediately =
|
|
1755
|
+
!shouldDebounceMessage ||
|
|
1756
|
+
existing.items.length >= DEFAULT_DEBOUNCE_MAX_BUFFER;
|
|
1757
|
+
const baseDelayMs = shouldFlushImmediately ? 0 : behavior.debounceMs;
|
|
1758
|
+
const startupStaggerMs =
|
|
1759
|
+
startupConnectedAtMs > 0 &&
|
|
1760
|
+
Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
|
|
1761
|
+
!wasExplicitlyAddressed
|
|
1762
|
+
? randomIntInRange(
|
|
1763
|
+
STARTUP_STAGGER_MIN_DELAY_MS,
|
|
1764
|
+
STARTUP_STAGGER_MAX_DELAY_MS,
|
|
1765
|
+
)
|
|
1766
|
+
: 0;
|
|
1767
|
+
const delayMs = baseDelayMs + startupStaggerMs;
|
|
980
1768
|
existing.timer = setTimeout(() => {
|
|
981
1769
|
void dispatchConversationBatch(key);
|
|
982
|
-
},
|
|
1770
|
+
}, delayMs);
|
|
983
1771
|
};
|
|
984
1772
|
|
|
985
1773
|
const dropPendingMessage = async (messageId: string): Promise<void> => {
|
|
986
1774
|
for (const [key, pending] of pendingBatches) {
|
|
987
|
-
const index = pending.items.findIndex(
|
|
1775
|
+
const index = pending.items.findIndex(
|
|
1776
|
+
(item) => item.msg.id === messageId,
|
|
1777
|
+
);
|
|
988
1778
|
if (index === -1) continue;
|
|
989
1779
|
const [removed] = pending.items.splice(index, 1);
|
|
990
|
-
await removed.
|
|
1780
|
+
await removed.clearAckReaction();
|
|
991
1781
|
if (pending.items.length === 0) {
|
|
992
1782
|
clearTimeout(pending.timer);
|
|
1783
|
+
pending.typingController.stop();
|
|
1784
|
+
await pending.lifecycleController?.clear();
|
|
993
1785
|
pendingBatches.delete(key);
|
|
994
1786
|
}
|
|
995
1787
|
return;
|
|
@@ -1000,21 +1792,35 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1000
1792
|
messageId: string,
|
|
1001
1793
|
nextMsg: DiscordMessage,
|
|
1002
1794
|
nextContent: string,
|
|
1795
|
+
nextBehavior: ResolvedChannelBehavior,
|
|
1003
1796
|
): Promise<boolean> => {
|
|
1004
1797
|
for (const [key, pending] of pendingBatches) {
|
|
1005
|
-
const index = pending.items.findIndex(
|
|
1798
|
+
const index = pending.items.findIndex(
|
|
1799
|
+
(item) => item.msg.id === messageId,
|
|
1800
|
+
);
|
|
1006
1801
|
if (index === -1) continue;
|
|
1007
1802
|
|
|
1008
1803
|
if (!nextContent) {
|
|
1009
1804
|
const [removed] = pending.items.splice(index, 1);
|
|
1010
|
-
await removed.
|
|
1805
|
+
await removed.clearAckReaction();
|
|
1011
1806
|
} else {
|
|
1012
1807
|
pending.items[index].msg = nextMsg;
|
|
1013
1808
|
pending.items[index].content = nextContent;
|
|
1809
|
+
pending.items[index].behavior = nextBehavior;
|
|
1810
|
+
pending.items[index].wasExplicitlyAddressed =
|
|
1811
|
+
!nextMsg.guild ||
|
|
1812
|
+
hasPrefixInvocation(nextMsg.content || '') ||
|
|
1813
|
+
Boolean(client.user && nextMsg.mentions.has(client.user));
|
|
1814
|
+
pending.items[index].cooldownKey = buildConversationCooldownKey(
|
|
1815
|
+
nextMsg.channelId,
|
|
1816
|
+
nextMsg.author.id,
|
|
1817
|
+
);
|
|
1014
1818
|
}
|
|
1015
1819
|
|
|
1016
1820
|
if (pending.items.length === 0) {
|
|
1017
1821
|
clearTimeout(pending.timer);
|
|
1822
|
+
pending.typingController.stop();
|
|
1823
|
+
await pending.lifecycleController?.clear();
|
|
1018
1824
|
pendingBatches.delete(key);
|
|
1019
1825
|
}
|
|
1020
1826
|
return true;
|
|
@@ -1028,6 +1834,7 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1028
1834
|
const sessionId = getSessionId(msg);
|
|
1029
1835
|
const guildId = msg.guild?.id || null;
|
|
1030
1836
|
const channelId = msg.channelId;
|
|
1837
|
+
const behavior = resolveChannelBehavior(msg);
|
|
1031
1838
|
const content = cleanIncomingContent(msg.content);
|
|
1032
1839
|
observeMessageParticipants(msg, content);
|
|
1033
1840
|
const immediateMentionLookup = buildMentionLookup(
|
|
@@ -1037,11 +1844,23 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1037
1844
|
);
|
|
1038
1845
|
|
|
1039
1846
|
const reply: ReplyFn = async (text, files) => {
|
|
1040
|
-
await sendChunkedReply(
|
|
1847
|
+
await sendChunkedReply(
|
|
1848
|
+
msg,
|
|
1849
|
+
text,
|
|
1850
|
+
files,
|
|
1851
|
+
immediateMentionLookup,
|
|
1852
|
+
behavior.humanDelay,
|
|
1853
|
+
);
|
|
1041
1854
|
};
|
|
1042
1855
|
const commandReply: ReplyFn = async (text, files) => {
|
|
1043
1856
|
try {
|
|
1044
|
-
await sendChunkedDirectReply(
|
|
1857
|
+
await sendChunkedDirectReply(
|
|
1858
|
+
msg,
|
|
1859
|
+
text,
|
|
1860
|
+
files,
|
|
1861
|
+
immediateMentionLookup,
|
|
1862
|
+
behavior.humanDelay,
|
|
1863
|
+
);
|
|
1045
1864
|
} catch (error) {
|
|
1046
1865
|
logger.warn(
|
|
1047
1866
|
{ error, userId: msg.author.id, channelId: msg.channelId },
|
|
@@ -1071,15 +1890,23 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1071
1890
|
if (!content) {
|
|
1072
1891
|
await commandReply(`How can I help? Try \`${DISCORD_PREFIX} help\`.`);
|
|
1073
1892
|
} else {
|
|
1074
|
-
await commandReply(
|
|
1893
|
+
await commandReply(
|
|
1894
|
+
`Unknown command. Try \`${DISCORD_PREFIX} help\`.`,
|
|
1895
|
+
);
|
|
1075
1896
|
}
|
|
1076
1897
|
return;
|
|
1077
1898
|
}
|
|
1078
|
-
await commandHandler(
|
|
1899
|
+
await commandHandler(
|
|
1900
|
+
sessionId,
|
|
1901
|
+
guildId,
|
|
1902
|
+
channelId,
|
|
1903
|
+
[parsed.command, ...parsed.args],
|
|
1904
|
+
commandReply,
|
|
1905
|
+
);
|
|
1079
1906
|
return;
|
|
1080
1907
|
}
|
|
1081
1908
|
|
|
1082
|
-
if (!isTrigger(msg)) return;
|
|
1909
|
+
if (!isTrigger(msg, behavior)) return;
|
|
1083
1910
|
|
|
1084
1911
|
if (ignorePrefixCommand) {
|
|
1085
1912
|
return;
|
|
@@ -1092,7 +1919,13 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1092
1919
|
'Ignoring unauthorized Discord command; processing as normal chat message',
|
|
1093
1920
|
);
|
|
1094
1921
|
} else {
|
|
1095
|
-
await commandHandler(
|
|
1922
|
+
await commandHandler(
|
|
1923
|
+
sessionId,
|
|
1924
|
+
guildId,
|
|
1925
|
+
channelId,
|
|
1926
|
+
[parsed.command, ...parsed.args],
|
|
1927
|
+
commandReply,
|
|
1928
|
+
);
|
|
1096
1929
|
return;
|
|
1097
1930
|
}
|
|
1098
1931
|
}
|
|
@@ -1102,7 +1935,18 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1102
1935
|
return;
|
|
1103
1936
|
}
|
|
1104
1937
|
|
|
1105
|
-
await
|
|
1938
|
+
const readWithoutReplyHandled = await maybeHandleReadWithoutReply(
|
|
1939
|
+
msg,
|
|
1940
|
+
content,
|
|
1941
|
+
);
|
|
1942
|
+
if (readWithoutReplyHandled) {
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
const rateLimitAllowed = await enforcePerUserRateLimit(msg, behavior);
|
|
1947
|
+
if (!rateLimitAllowed) return;
|
|
1948
|
+
|
|
1949
|
+
await queueConversationMessage(msg, content, behavior);
|
|
1106
1950
|
});
|
|
1107
1951
|
|
|
1108
1952
|
client.on('messageUpdate', async (_oldMsg, nextMsg) => {
|
|
@@ -1114,14 +1958,19 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1114
1958
|
if (fetched.author?.bot) return;
|
|
1115
1959
|
|
|
1116
1960
|
const updatedContent = cleanIncomingContent(fetched.content || '');
|
|
1961
|
+
const behavior = resolveChannelBehavior(fetched);
|
|
1117
1962
|
observeMessageParticipants(fetched, updatedContent);
|
|
1118
|
-
if (!isTrigger(fetched))
|
|
1119
|
-
|
|
1963
|
+
if (!isTrigger(fetched, behavior)) {
|
|
1964
|
+
await updatePendingMessage(fetched.id, fetched, '', behavior);
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
await updatePendingMessage(fetched.id, fetched, updatedContent, behavior);
|
|
1120
1968
|
|
|
1121
1969
|
const inFlight = inFlightByMessageId.get(fetched.id);
|
|
1122
1970
|
if (!inFlight || inFlight.aborted) return;
|
|
1123
1971
|
inFlight.aborted = true;
|
|
1124
1972
|
inFlight.abortController.abort();
|
|
1973
|
+
inFlight.emitLifecyclePhase('error');
|
|
1125
1974
|
for (const messageId of inFlight.messageIds) {
|
|
1126
1975
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
1127
1976
|
inFlightByMessageId.delete(messageId);
|
|
@@ -1129,7 +1978,9 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1129
1978
|
}
|
|
1130
1979
|
await inFlight.stream.discard();
|
|
1131
1980
|
if (updatedContent) {
|
|
1132
|
-
await
|
|
1981
|
+
const rateLimitAllowed = await enforcePerUserRateLimit(fetched, behavior);
|
|
1982
|
+
if (!rateLimitAllowed) return;
|
|
1983
|
+
await queueConversationMessage(fetched, updatedContent, behavior);
|
|
1133
1984
|
}
|
|
1134
1985
|
});
|
|
1135
1986
|
|
|
@@ -1139,6 +1990,7 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1139
1990
|
if (!inFlight || inFlight.aborted) return;
|
|
1140
1991
|
inFlight.aborted = true;
|
|
1141
1992
|
inFlight.abortController.abort();
|
|
1993
|
+
inFlight.emitLifecyclePhase('error');
|
|
1142
1994
|
for (const messageId of inFlight.messageIds) {
|
|
1143
1995
|
if (inFlightByMessageId.get(messageId) === inFlight) {
|
|
1144
1996
|
inFlightByMessageId.delete(messageId);
|
|
@@ -1174,16 +2026,30 @@ export function initDiscord(onMessage: MessageHandler, onCommand: CommandHandler
|
|
|
1174
2026
|
return client;
|
|
1175
2027
|
}
|
|
1176
2028
|
|
|
2029
|
+
export async function setDiscordMaintenancePresence(): Promise<void> {
|
|
2030
|
+
if (!presenceController) return;
|
|
2031
|
+
await presenceController.setMaintenance();
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1177
2034
|
/**
|
|
1178
2035
|
* Send a message to a channel by ID (used by scheduler).
|
|
1179
2036
|
*/
|
|
1180
|
-
export async function sendToChannel(
|
|
2037
|
+
export async function sendToChannel(
|
|
2038
|
+
channelId: string,
|
|
2039
|
+
text: string,
|
|
2040
|
+
files?: AttachmentBuilder[],
|
|
2041
|
+
): Promise<void> {
|
|
1181
2042
|
const channel = await client.channels.fetch(channelId);
|
|
1182
2043
|
if (channel && 'send' in channel) {
|
|
1183
2044
|
const payloads = prepareChunkedPayloads(text, files);
|
|
1184
|
-
const send = (
|
|
1185
|
-
|
|
1186
|
-
|
|
2045
|
+
const send = (
|
|
2046
|
+
channel as unknown as {
|
|
2047
|
+
send: (payload: {
|
|
2048
|
+
content: string;
|
|
2049
|
+
files?: AttachmentBuilder[];
|
|
2050
|
+
}) => Promise<void>;
|
|
2051
|
+
}
|
|
2052
|
+
).send;
|
|
1187
2053
|
for (const payload of payloads) {
|
|
1188
2054
|
await withDiscordRetry('send-channel', () => send(payload));
|
|
1189
2055
|
}
|