import { ChatInputCommandInteraction, Client, GatewayIntentBits, MessageType, Partials } from "discord.js"; import { applySelfbotPatches } from "./selfbot/selfbotPatches.ts"; import { createStreamDiscoveryState, createGoLiveStreamState, buildStreamKey, buildGoLiveStreamStateFromStream, removeSessionGoLiveStream, setupStreamDiscovery, syncPrimaryGoLiveStream, upsertSessionGoLiveStream, type StreamDiscoveryState } from "./selfbot/streamDiscovery.ts"; import type { Settings } from "./settings/settingsSchema.ts"; import { runCodeAgent, isCodeAgentUserAllowed, normalizeCodeAgentRole, resolveCodeAgentConfig, getActiveCodeAgentTaskCount } from "./agents/codeAgent.ts"; import { ImageCaptionCache } from "./vision/imageCaptionCache.ts"; import { normalizeReactionEmojiToken } from "./bot/botHelpers.ts"; import { resolveFollowingNextRunAt, resolveInitialNextRunAt } from "./bot/automation.ts"; import { chance } from "./utils.ts"; import { applyAutomationControlAction, composeAutomationControlReply } from "./bot/automationControl.ts"; import { finalizeReplyPerformanceSample, normalizeReplyPerformanceSeed } from "./bot/replyPipelineShared.ts"; import type { ReplyPerformanceSeed } from "./bot/replyPipelineShared.ts"; import { runModelRequestedBrowserBrowse, runModelRequestedCodeTask, buildSubAgentSessionsRuntime, stopMinecraftMcpServer } from "./bot/agentTasks.ts"; import { buildBrowserBrowseContext, } from "./bot/budgetTracking.ts"; import { composeMessageContentForHistory, recordReactionHistoryEvent, syncMessageSnapshot, syncMessageSnapshotFromReaction } from "./bot/messageHistory.ts"; import { generateTextCancelAcknowledgement } from "./bot/textCancelAcknowledgement.ts"; import { isChannelAllowed, isDiscoveryChannel, isReplyChannel, isUserBlocked } from "./bot/permissions.ts"; import { evaluateReplyAdmissionDecision, getReplyAddressSignal, hasStartupFollowupAfterMessage } from "./bot/replyAdmission.ts"; import { buildRuntimeDecisionCorrelation } from "./services/runtimeCorrelation.ts"; import { runStartupCatchup } from "./bot/startupCatchup.ts"; import { maybeRunInitiativeCycle } from "./bot/initiativeEngine.ts"; import { getVoiceScreenWatchCapability, startVoiceScreenWatch, } from "./bot/screenShare.ts"; import type { ScreenShareSessionManagerLike } from "./bot/screenShare.ts"; import { composeVoiceOperationalMessage, generateVoiceTurnReply, requestVoiceJoinFromDashboard } from "./bot/voiceCoordination.ts"; import { maybeRunAutomationCycle } from "./bot/automationEngine.ts"; import { dequeueReplyBurst, dequeueReplyJob, ensureGatewayHealthy, getReplyQueueWaitMs, processReplyQueue, reconnectGateway, requeueReplyJobs, scheduleReconnect } from "./bot/queueGateway.ts"; import type { AgentContext, BotContext, BudgetContext, MediaAttachmentContext, QueueGatewayRuntime, ReplyPipelineRuntime, VoiceReplyRuntime } from "./bot/botContext.ts"; import { buildAgentContext, buildAutomationEngineRuntime, buildBotContext, buildBudgetContext, buildInitiativeRuntime, buildMediaAttachmentContext, buildQueueGatewayRuntime, buildReplyPipelineRuntime, buildScreenShareRuntime, buildVoiceCoordinationRuntime, buildVoiceReplyRuntime } from "./bot/botRuntimeFactories.ts"; import { VoiceSessionManager } from "./voice/voiceSessionManager.ts"; import type { BrowserManager } from "./services/BrowserManager.ts"; import { isAbortError } from "./tools/abortError.ts"; import { BrowserTaskRegistry, buildBrowserTaskScopeKey } from "./tools/browserTaskRuntime.ts"; import { ActiveReplyRegistry, buildTextReplyScopeKey } from "./tools/activeReplyRegistry.ts"; import { isCancelIntent } from "./tools/cancelDetection.ts"; import { maybeReplyToMessagePipeline } from "./bot/replyPipeline.ts"; import { SubAgentSessionManager } from "./agents/subAgentSession.ts"; import { BackgroundTaskRunner, buildCodeTaskScopeKey, type BackgroundTask } from "./agents/backgroundTaskRunner.ts"; import { getMemorySettings, getBotName, getReplyPermissions, getActivitySettings, isDevTaskEnabled } from "./settings/agentStack.ts"; import { buildCodeTaskResultPrompt } from "./prompts/promptText.ts";
const REPLY_QUEUE_MAX_PER_CHANNEL = 60; const TEXT_CANCEL_FALLBACK_REACTION = "🛑"; const STARTUP_TASK_DELAY_MS = 4500; const INITIATIVE_TICK_MS = 60_000; const AUTOMATION_TICK_MS = 30_000; const GATEWAY_WATCHDOG_TICK_MS = 30_000; const REFLECTION_TICK_MS = 60_000; const UNSOLICITED_REPLY_CONTEXT_WINDOW = 5; const IS_TEST_PROCESS = /.test.[cm]?[jt]sx?$/i.test(String(process.argv?.[1] || "")) || process.execArgv.includes("--test") || process.argv.includes("--test"); export type ReplyAttemptOptions = { recentMessages?: Array<Record<string, unknown>>; addressSignal?: { direct?: boolean; inferred?: boolean; triggered?: boolean; reason?: string; confidence?: number; threshold?: number; confidenceSource?: "llm" | "fallback" | "direct" | "exact_name"; } | null; triggerMessageIds?: string[]; forceRespond?: boolean; forceDecisionLoop?: boolean; source?: string; performanceSeed?: ReplyPerformanceSeed | null; };
type SentMessageLike = { id: string; createdTimestamp: number; guildId: string | null; channelId: string; content?: string; attachments?: unknown; embeds?: unknown[]; };
type CachedChannelLike = { id: string; name?: string; send?: (payload: unknown) => Promise; sendTyping?: () => Promise; isTextBased?: () => boolean; isVoiceBased?: () => boolean; parent?: { name?: string } | null; };
type GuildMemberLike = { displayName?: string; user?: { username?: string; } | null; };
type GuildLike = { id: string; name: string; members?: { cache?: { get: (id: string) => GuildMemberLike | undefined; }; }; channels?: { cache: { get: (id: string) => CachedChannelLike | undefined; values: () => IterableIterator; filter: (predicate: (channel: CachedChannelLike) => boolean) => { first: (count: number) => CachedChannelLike[]; }; }; }; };
type DiscordClientLike = { on: Client["on"]; destroy: Client["destroy"]; isReady: Client["isReady"]; login: Client["login"]; user: { id?: string; username?: string; tag?: string; } | null; guilds: { cache: { get: (id: string) => GuildLike | undefined; values: () => IterableIterator; size: number; }; }; channels: { cache: { get: (id: string) => CachedChannelLike | undefined; }; }; users?: { cache?: { get: (id: string) => { username?: string; } | undefined; }; }; };
function isSendableChannel( channel: CachedChannelLike | null | undefined ): channel is CachedChannelLike & { send: (payload: unknown) => Promise; sendTyping: () => Promise; } { return Boolean(channel) && channel.isTextBased?.() === true && typeof channel.send === "function" && typeof channel.sendTyping === "function"; }
function isAppCommandInvocationMessage(message: { type?: number | null } | null | undefined) { return message?.type === MessageType.ChatInputCommand || message?.type === MessageType.ContextMenuCommand; }
export class ClankerBot { appConfig; store; llm; memory; discovery; search; gifs; video; lastBotMessageAt; memoryTimer; initiativeTimer; automationTimer; gatewayWatchdogTimer; reconnectTimeout; startupTasksRan; startupTimeout; initiativeCycleRunning; pendingInitiativeThoughts; automationCycleRunning; reconnectInFlight; isStopping; hasConnectedAtLeastOnce; lastGatewayEventAt; reconnectAttempts; replyQueues; replyQueueWorkers; replyQueuedMessageIds: Set; reflectionTimer; nextReflectionRunAt: string | null; screenShareSessionManager: ScreenShareSessionManagerLike | null; client: DiscordClientLike; voiceSessionManager: VoiceSessionManager; browserManager: BrowserManager | null; activeReplies: ActiveReplyRegistry; activeBrowserTasks: BrowserTaskRegistry; subAgentSessions: SubAgentSessionManager; backgroundTaskRunner: BackgroundTaskRunner; imageCaptionCache: ImageCaptionCache; streamDiscovery: StreamDiscoveryState; private streamDiscoveryCleanup: (() => void) | null; private captionTimestamps: number[];
constructor({ appConfig, store, llm, memory, discovery, search, gifs, video, browserManager = null }) { this.appConfig = appConfig; this.store = store; this.llm = llm; this.memory = memory; this.discovery = discovery; this.search = search; this.gifs = gifs; this.video = video; this.browserManager = browserManager;
this.lastBotMessageAt = 0;
this.memoryTimer = null;
this.initiativeTimer = null;
this.automationTimer = null;
this.gatewayWatchdogTimer = null;
this.reconnectTimeout = null;
this.startupTasksRan = false;
this.startupTimeout = null;
this.initiativeCycleRunning = false;
this.pendingInitiativeThoughts = new Map();
this.automationCycleRunning = false;
this.reconnectInFlight = false;
this.isStopping = false;
this.hasConnectedAtLeastOnce = false;
this.lastGatewayEventAt = Date.now();
this.reconnectAttempts = 0;
this.replyQueues = new Map();
this.replyQueueWorkers = new Set();
this.replyQueuedMessageIds = new Set();
this.reflectionTimer = null;
this.nextReflectionRunAt = null;
this.screenShareSessionManager = null;
this.activeReplies = new ActiveReplyRegistry();
this.activeBrowserTasks = new BrowserTaskRegistry();
this.subAgentSessions = new SubAgentSessionManager({
idleTimeoutMs: Number(appConfig?.subAgentOrchestration?.sessionIdleTimeoutMs) || 300_000,
maxSessions: Number(appConfig?.subAgentOrchestration?.maxConcurrentSessions) || 20
});
this.subAgentSessions.startSweep();
this.backgroundTaskRunner = new BackgroundTaskRunner({
store: this.store,
sessionManager: this.subAgentSessions
});
this.imageCaptionCache = new ImageCaptionCache({
maxEntries: 200,
defaultTtlMs: 60 * 60 * 1000 // 1 hour
});
this.captionTimestamps = [];
this.streamDiscovery = createStreamDiscoveryState();
this.streamDiscoveryCleanup = null;
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.MessageContent
],
partials: [Partials.Channel, Partials.Message, Partials.Reaction]
});
applySelfbotPatches(this.client as Client);
this.voiceSessionManager = new VoiceSessionManager({
client: this.client,
store: this.store,
appConfig: this.appConfig,
llm: this.llm,
memory: this.memory,
search: this.search,
browserManager: this.browserManager,
composeOperationalMessage: (payload) =>
composeVoiceOperationalMessage(this.toVoiceCoordinationRuntime(), payload),
generateVoiceTurn: (payload) =>
generateVoiceTurnReply(this.toVoiceCoordinationRuntime(), payload),
activeReplies: this.activeReplies,
streamDiscovery: this.streamDiscovery,
getVoiceScreenWatchCapability: (payload) =>
getVoiceScreenWatchCapability(this.toScreenShareRuntime(), payload),
startVoiceScreenWatch: (payload) =>
startVoiceScreenWatch(this.toScreenShareRuntime(), payload)
});
// Wire code agent hooks onto VoiceSessionManager so code_task is
// available on the voice_realtime surface (provider-native tool calls).
const voiceAgentContext = this.toAgentContext();
this.voiceSessionManager.runModelRequestedCodeTask = (payload) =>
runModelRequestedCodeTask(voiceAgentContext, {
...payload,
settings: payload.settings || this.store.getSettings()
});
this.voiceSessionManager.createCodeAgentSession = (opts) => {
const sessionsRuntime = buildSubAgentSessionsRuntime(voiceAgentContext);
return sessionsRuntime.createCodeSession({
...opts,
settings: opts.settings || this.store.getSettings()
}) ?? null;
};
this.voiceSessionManager.dispatchBackgroundCodeTask = (payload) =>
this.dispatchBackgroundCodeTask(payload);
this.voiceSessionManager.createMinecraftSession = (opts) => {
const sessionsRuntime = buildSubAgentSessionsRuntime(voiceAgentContext);
return sessionsRuntime.createMinecraftSession({
...opts,
settings: opts.settings || this.store.getSettings()
}) ?? null;
};
this.voiceSessionManager.subAgentSessions = this.subAgentSessions;
this.registerEvents();
}
attachScreenShareSessionManager(manager: ScreenShareSessionManagerLike | null) { this.screenShareSessionManager = manager || null; }
toBotContext(): BotContext { return buildBotContext(this); }
toAgentContext(): AgentContext { return buildAgentContext(this); }
toBudgetContext(): BudgetContext { return buildBudgetContext(this); }
toMediaAttachmentContext(): MediaAttachmentContext { return buildMediaAttachmentContext(this); }
toScreenShareRuntime() { return buildScreenShareRuntime(this); }
toVoiceCoordinationRuntime() { return buildVoiceCoordinationRuntime(this); }
toInitiativeRuntime() { return buildInitiativeRuntime(this); }
toAutomationEngineRuntime() { return buildAutomationEngineRuntime(this); }
toQueueGatewayRuntime(): QueueGatewayRuntime { return buildQueueGatewayRuntime(this); }
toReplyPipelineRuntime(): ReplyPipelineRuntime { return buildReplyPipelineRuntime(this, { captionTimestamps: this.captionTimestamps, unsolicitedReplyContextWindow: UNSOLICITED_REPLY_CONTEXT_WINDOW }); }
toVoiceReplyRuntime(): VoiceReplyRuntime { return buildVoiceReplyRuntime(this); }
async handleClankSlashCommand(interaction: ChatInputCommandInteraction) { const settings = this.store.getSettings(); const subcommandGroup = interaction.options.getSubcommandGroup(false);
if (subcommandGroup === "music") {
return await this.voiceSessionManager.handleClankSlashCommand(interaction, settings);
}
const subcommand = interaction.options.getSubcommand(true);
if (subcommand === "say") {
return await this.voiceSessionManager.handleClankSlashCommand(interaction, settings);
}
if (subcommand === "browse") {
return await this.handleClankBrowseSlashCommand(interaction, settings);
}
if (subcommand === "code") {
return await this.handleClankCodeSlashCommand(interaction, settings);
}
await interaction.reply({ content: "Unsupported /clank command.", ephemeral: true });
}
async handleClankBrowseSlashCommand( interaction: ChatInputCommandInteraction, settings: Settings ) { await interaction.deferReply(); const browseInstruction = interaction.options.getString("task", true);
try {
const browserContext = buildBrowserBrowseContext(
this.toBudgetContext(),
settings
);
if (!browserContext.configured) {
await interaction.editReply("Browser runtime is currently unavailable on this server.");
return;
}
if (!browserContext.enabled) {
await interaction.editReply("Browser runtime is disabled in settings on this server.");
return;
}
const result = await runModelRequestedBrowserBrowse(this.toAgentContext(), {
settings,
browserBrowse: browserContext,
query: browseInstruction,
guildId: interaction.guildId,
channelId: interaction.channelId,
userId: interaction.user.id,
source: "slash_command_clank_browse"
});
let responseText = String(result.text || "").trim();
if (result.error) {
responseText = result.error;
}
if (result.hitStepLimit) {
responseText += "
(Note: I reached my maximum step limit before finishing the task completely.)"; } if (!responseText) { responseText = "Browser task completed with no text result."; }
if (responseText.length > 2000) {
await interaction.editReply(responseText.substring(0, 1997) + "...");
} else {
await interaction.editReply(responseText);
}
} catch (error) {
this.store.logAction({ kind: "bot_error", guildId: interaction.guildId, channelId: interaction.channelId, userId: interaction.user.id, content: "slash_command_browse_error", metadata: { error: String(error instanceof Error ? error.message : error) } });
const message = error instanceof Error ? error.message : String(error);
if (isAbortError(error)) {
try {
await interaction.editReply("Browser session was cancelled.");
} catch (replyError) {
this.store.logAction({ kind: "bot_error", guildId: interaction.guildId, channelId: interaction.channelId, userId: interaction.user.id, content: "slash_command_browse_cancelled_reply_failed", metadata: { error: String(replyError instanceof Error ? replyError.message : replyError) } });
}
} else {
try {
await interaction.editReply(`An error occurred while browsing: ${message}`);
} catch (replyError) {
this.store.logAction({ kind: "bot_error", guildId: interaction.guildId, channelId: interaction.channelId, userId: interaction.user.id, content: "slash_command_browse_error_reply_failed", metadata: { error: String(replyError instanceof Error ? replyError.message : replyError) } });
}
}
}
}
async handleClankCodeSlashCommand( interaction: ChatInputCommandInteraction, settings: Settings ) { await interaction.deferReply(); const codeInstruction = interaction.options.getString("task", true); const codeRole = normalizeCodeAgentRole(interaction.options.getString("role", false), "implementation"); const codeCwd = interaction.options.getString("cwd", false) || undefined;
if (!isDevTaskEnabled(settings)) {
await interaction.editReply("Code agent is disabled in settings.");
return;
}
if (!isCodeAgentUserAllowed(interaction.user.id, settings)) {
await interaction.editReply("This capability is restricted to allowed users.");
return;
}
const codeAgentConfig = resolveCodeAgentConfig(settings, codeCwd, codeRole);
const maxParallel = codeAgentConfig.maxParallelTasks;
if (getActiveCodeAgentTaskCount() >= maxParallel) {
await interaction.editReply("Too many code agent tasks are already running. Try again shortly.");
return;
}
const maxPerHour = codeAgentConfig.maxTasksPerHour;
const since1h = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const usedThisHour = this.store.countActionsSince("code_agent_call", since1h);
if (usedThisHour >= maxPerHour) {
await interaction.editReply("Code agent is currently blocked by hourly limits. Try again shortly.");
return;
}
try {
const {
cwd,
swarm,
workspaceMode,
provider,
model,
codexCliModel,
maxTurns,
timeoutMs,
maxBufferBytes
} = codeAgentConfig;
const result = await runCodeAgent({
instruction: codeInstruction,
cwd,
swarm,
workspaceMode,
provider,
maxTurns,
timeoutMs,
maxBufferBytes,
model,
codexCliModel,
trace: {
guildId: interaction.guildId,
channelId: interaction.channelId,
userId: interaction.user.id,
source: "slash_command_clank_code",
role: codeRole
},
store: this.store
});
let responseText = result.text;
if (result.costUsd > 0) {
responseText += `
(Cost: $${result.costUsd.toFixed(4)}); } if (responseText.length > 2000) { await interaction.editReply(responseText.substring(0, 1997) + "..."); } else { await interaction.editReply(responseText || "Code task completed with no output."); } } catch (error) { this.store.logAction({ kind: "bot_error", guildId: interaction.guildId, channelId: interaction.channelId, userId: interaction.user.id, content: "slash_command_code_error", metadata: { error: String(error instanceof Error ? error.message : error) } }); const message = error instanceof Error ? error.message : String(error); try { await interaction.editReply(An error occurred while running code task: ${message}`);
} catch (replyError) {
this.store.logAction({ kind: "bot_error", guildId: interaction.guildId, channelId: interaction.channelId, userId: interaction.user.id, content: "slash_command_code_error_reply_failed", metadata: { error: String(replyError instanceof Error ? replyError.message : replyError) } });
}
}
}
registerEvents() { this.client.on("clientReady", async () => { this.hasConnectedAtLeastOnce = true; this.reconnectAttempts = 0; this.lastGatewayEventAt = Date.now(); this.store.logAction({ kind: "bot_lifecycle", content: "bot_logged_in", metadata: { tag: this.client.user?.tag || this.client.user?.username || "unknown" } });
this.streamDiscoveryCleanup = setupStreamDiscovery(
this.client as never,
this.streamDiscovery,
{
onGoLiveDetected: ({ userId, guildId, channelId }) => {
const isSelfStream = String(userId || "").trim() === String(this.client.user?.id || "").trim();
const session = this.voiceSessionManager.getSession(guildId);
if (session && !isSelfStream) {
const streamKey = buildStreamKey(guildId, channelId, userId);
const existingGoLiveStream = session.goLiveStreams?.get(streamKey);
if (existingGoLiveStream) {
return;
}
upsertSessionGoLiveStream(session, {
...createGoLiveStreamState(),
streamKey,
targetUserId: userId,
guildId,
channelId,
discoveredAt: Date.now(),
});
this.store.logAction({
kind: "stream_discovery",
guildId,
channelId,
userId,
content: `stream_discovery_go_live_bootstrap_seeded: streamKey=${streamKey}`,
metadata: { streamKey }
});
}
},
onGoLiveEnded: ({ userId, guildId, channelId }) => {
const isSelfStream = String(userId || "").trim() === String(this.client.user?.id || "").trim();
const session = this.voiceSessionManager.getSession(guildId);
const provisionalStreamKey = channelId ? buildStreamKey(guildId, channelId, userId) : null;
if (
session &&
!isSelfStream &&
(provisionalStreamKey
? session.goLiveStreams?.has(provisionalStreamKey)
: [...(session.goLiveStreams?.values() || [])].some((stream) =>
String(stream.targetUserId || "").trim() === String(userId || "").trim() &&
String(stream.guildId || "").trim() === String(guildId || "").trim() &&
(!channelId || String(stream.channelId || "").trim() === String(channelId || "").trim())
))
) {
this.store.logAction({
kind: "stream_discovery",
guildId,
channelId,
userId,
content: `stream_discovery_go_live_bootstrap_cleared: streamKey=${provisionalStreamKey ?? session.goLiveStream?.streamKey ?? "unknown"}`,
metadata: {
streamKey: provisionalStreamKey ?? session.goLiveStream?.streamKey ?? null,
reason: "voice_state_self_stream_false"
}
});
removeSessionGoLiveStream(session, {
streamKey: provisionalStreamKey,
targetUserId: userId
});
}
},
onStreamDiscovered: (stream) => {
this.store.logAction({
kind: "stream_discovery",
guildId: stream.guildId,
channelId: stream.channelId,
userId: stream.userId,
content: `stream_discovered: streamKey=${stream.streamKey} rtcServerId=${stream.rtcServerId ?? "unknown"}`,
metadata: { streamKey: stream.streamKey, rtcServerId: stream.rtcServerId }
});
const isSelfStream = String(stream.userId || "").trim() === String(this.client.user?.id || "").trim();
const session = this.voiceSessionManager.getSession(stream.guildId);
if (session && !isSelfStream) {
upsertSessionGoLiveStream(session, buildGoLiveStreamStateFromStream(stream), stream.userId);
}
},
onStreamCredentialsReceived: (stream) => {
this.store.logAction({
kind: "stream_discovery",
guildId: stream.guildId,
channelId: stream.channelId,
userId: stream.userId,
content: `stream_credentials_received: streamKey=${stream.streamKey} hasEndpoint=${Boolean(stream.endpoint)} hasToken=${Boolean(stream.token)}`,
metadata: { streamKey: stream.streamKey, rtcServerId: stream.rtcServerId }
});
const isSelfStream = String(stream.userId || "").trim() === String(this.client.user?.id || "").trim();
const session = this.voiceSessionManager.getSession(stream.guildId);
if (session && !isSelfStream) {
upsertSessionGoLiveStream(session, buildGoLiveStreamStateFromStream(stream), stream.userId);
}
if (isSelfStream) {
this.voiceSessionManager.handleDiscoveredSelfStreamCredentialsReceived({ stream });
} else {
this.voiceSessionManager.handleDiscoveredStreamCredentialsReceived({ stream });
}
},
onStreamDeleted: (stream) => {
this.store.logAction({
kind: "stream_discovery",
guildId: stream.guildId,
channelId: stream.channelId,
userId: stream.userId,
content: `stream_deleted: streamKey=${stream.streamKey}`,
metadata: { streamKey: stream.streamKey }
});
const isSelfStream = String(stream.userId || "").trim() === String(this.client.user?.id || "").trim();
const session = this.voiceSessionManager.getSession(stream.guildId);
if (!isSelfStream && session) {
removeSessionGoLiveStream(session, {
streamKey: stream.streamKey,
targetUserId: stream.userId
});
syncPrimaryGoLiveStream(session);
}
const handlerPromise = isSelfStream
? Promise.resolve(this.voiceSessionManager.handleDiscoveredSelfStreamDeleted({ stream }))
: this.voiceSessionManager.handleDiscoveredStreamDeleted({ stream });
void handlerPromise.catch((error) => {
this.store.logAction({
kind: "voice_error",
guildId: stream.guildId,
channelId: stream.channelId,
userId: stream.userId,
content: `stream_discovery_delete_handler_failed: ${String((error as Error)?.message || error)}`,
metadata: {
streamKey: stream.streamKey
}
});
});
},
onLog: (action, detail) => {
this.store.logAction({
kind: "stream_discovery",
guildId: (detail.guildId as string) ?? null,
channelId: (detail.channelId as string) ?? null,
userId: (detail.userId as string) ?? null,
content: `${action}: ${JSON.stringify(detail)}`
});
}
}
);
});
this.client.on("shardResume", () => {
this.lastGatewayEventAt = Date.now();
});
this.client.on("shardDisconnect", (event, shardId) => {
this.lastGatewayEventAt = Date.now();
this.store.logAction({
kind: "bot_error",
userId: this.client.user?.id,
content: `gateway_shard_disconnect: shard=${shardId} code=${event?.code ?? "unknown"}`
});
});
this.client.on("shardError", (error, shardId) => {
this.lastGatewayEventAt = Date.now();
this.store.logAction({
kind: "bot_error",
userId: this.client.user?.id,
content: `gateway_shard_error: shard=${shardId} ${String(error?.message || error)}`
});
});
this.client.on("error", (error) => {
this.lastGatewayEventAt = Date.now();
this.store.logAction({
kind: "bot_error",
userId: this.client.user?.id,
content: `gateway_error: ${String(error?.message || error)}`
});
});
this.client.on("invalidated", () => {
this.lastGatewayEventAt = Date.now();
this.store.logAction({
kind: "bot_error",
userId: this.client.user?.id,
content: "gateway_session_invalidated"
});
this.scheduleReconnect("session_invalidated", 2_000);
});
this.client.on("messageCreate", async (message) => {
try {
await this.handleMessage(message);
} catch (error) {
this.store.logAction({
kind: "bot_error",
guildId: message.guildId,
channelId: message.channelId,
messageId: message.id,
userId: message.author?.id,
content: String(error?.message || error)
});
}
});
this.client.on("guildMemberAdd", async (member) => {
try {
await this.handleMemberJoin(member);
} catch (error) {
this.store.logAction({
kind: "bot_error",
guildId: member?.guild?.id,
userId: member?.user?.id,
content: `member_join_handler: ${String(error?.message || error)}`
});
}
});
this.client.on("messageReactionAdd", async (reaction, user) => {
try {
await this.recordReactionHistoryEvent(reaction, user);
await this.syncMessageSnapshotFromReaction(reaction);
} catch (error) {
this.store.logAction({
kind: "bot_error",
guildId: reaction?.message?.guildId,
channelId: reaction?.message?.channelId,
messageId: reaction?.message?.id,
userId: this.client.user?.id,
content: `reaction_sync_add: ${String(error?.message || error)}`
});
}
});
this.client.on("messageReactionRemove", async (reaction) => {
try {
await this.syncMessageSnapshotFromReaction(reaction);
} catch (error) {
this.store.logAction({
kind: "bot_error",
guildId: reaction?.message?.guildId,
channelId: reaction?.message?.channelId,
messageId: reaction?.message?.id,
userId: this.client.user?.id,
content: `reaction_sync_remove: ${String(error?.message || error)}`
});
}
});
this.client.on("messageReactionRemoveAll", async (message) => {
try {
await this.syncMessageSnapshot(message);
} catch (error) {
this.store.logAction({
kind: "bot_error",
guildId: message?.guildId,
channelId: message?.channelId,
messageId: message?.id,
userId: this.client.user?.id,
content: `reaction_sync_remove_all: ${String(error?.message || error)}`
});
}
});
this.client.on("messageReactionRemoveEmoji", async (reaction) => {
try {
await this.syncMessageSnapshotFromReaction(reaction);
} catch (error) {
this.store.logAction({
kind: "bot_error",
guildId: reaction?.message?.guildId,
channelId: reaction?.message?.channelId,
messageId: reaction?.message?.id,
userId: this.client.user?.id,
content: `reaction_sync_remove_emoji: ${String(error?.message || error)}`
});
}
});
}
async start() { this.isStopping = false; await this.client.login(this.appConfig.discordToken); this.lastGatewayEventAt = Date.now();
this.memoryTimer = setInterval(() => {
this.memory.refreshMemoryMarkdown().catch((error) => {
this.store.logAction({
kind: "bot_error",
content: `memory_refresh: ${String(error?.message || error)}`
});
});
}, 5 * 60_000);
this.initiativeTimer = setInterval(() => {
maybeRunInitiativeCycle(this.toInitiativeRuntime()).catch((error) => {
this.store.logAction({
kind: "bot_error",
content: `initiative_cycle: ${String(error?.message || error)}`
});
});
}, INITIATIVE_TICK_MS);
this.automationTimer = setInterval(() => {
maybeRunAutomationCycle(this.toAutomationEngineRuntime()).catch((error) => {
this.store.logAction({
kind: "bot_error",
content: `automation_cycle: ${String(error?.message || error)}`
});
});
}, AUTOMATION_TICK_MS);
this.gatewayWatchdogTimer = setInterval(() => {
this.ensureGatewayHealthy().catch((error) => {
this.store.logAction({
kind: "bot_error",
userId: this.client.user?.id,
content: `gateway_watchdog: ${String(error?.message || error)}`
});
});
}, GATEWAY_WATCHDOG_TICK_MS);
this.reflectionTimer = setInterval(() => {
this.maybeRunReflection().catch((error) => {
this.store.logAction({
kind: "bot_error",
content: `reflection_cycle: ${String(error?.message || error)}`
});
});
}, REFLECTION_TICK_MS);
this.startupTimeout = setTimeout(() => {
if (this.isStopping) return;
this.runStartupTasks().catch((error) => {
this.store.logAction({
kind: "bot_error",
content: `startup_tasks: ${String(error?.message || error)}`
});
});
}, STARTUP_TASK_DELAY_MS);
}
async stop() { this.isStopping = true; if (this.startupTimeout) clearTimeout(this.startupTimeout); if (this.memoryTimer) clearInterval(this.memoryTimer); if (this.initiativeTimer) clearInterval(this.initiativeTimer); if (this.automationTimer) clearInterval(this.automationTimer); if (this.gatewayWatchdogTimer) clearInterval(this.gatewayWatchdogTimer); if (this.reflectionTimer) clearInterval(this.reflectionTimer); if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); this.initiativeTimer = null; this.gatewayWatchdogTimer = null; this.reflectionTimer = null; this.automationTimer = null; this.reconnectTimeout = null; this.startupTimeout = null; this.replyQueues.clear(); this.replyQueueWorkers.clear(); this.replyQueuedMessageIds.clear(); this.pendingInitiativeThoughts.clear(); this.streamDiscoveryCleanup?.(); this.streamDiscoveryCleanup = null; await this.voiceSessionManager.dispose("shutdown"); if (this.memory?.drainIngestQueue) { try { await this.memory.drainIngestQueue({ timeoutMs: 4000 }); } catch (error) { try { this.store.logAction({ kind: "bot_error", content: "shutdown_drain_memory_queue_failed", metadata: { error: String(error instanceof Error ? error.message : error) } }); } catch { /* store may be closing / } console.warn("[ClankerBot] Failed to drain memory ingest queue during shutdown:", error); } } if (this.browserManager?.closeAll) { try { await this.browserManager.closeAll(); } catch (error) { try { this.store.logAction({ kind: "bot_error", content: "shutdown_close_browser_sessions_failed", metadata: { error: String(error instanceof Error ? error.message : error) } }); } catch { / store may be closing */ } console.warn("[ClankerBot] Failed to close browser sessions during shutdown:", error); } } this.backgroundTaskRunner.close(); await stopMinecraftMcpServer(); await this.client.destroy(); }
getRuntimeState() { return { isReady: this.client.isReady(), userTag: this.client.user?.tag ?? null, guildCount: this.client.guilds.cache.size, lastBotMessageAt: this.lastBotMessageAt ? new Date(this.lastBotMessageAt).toISOString() : null, replyQueue: { channels: this.replyQueues.size, pending: this.getReplyQueuePendingCount() }, gateway: { hasConnectedAtLeastOnce: this.hasConnectedAtLeastOnce, reconnectInFlight: this.reconnectInFlight, reconnectAttempts: this.reconnectAttempts, lastGatewayEventAt: this.lastGatewayEventAt ? new Date(this.lastGatewayEventAt).toISOString() : null }, voice: this.voiceSessionManager.getRuntimeState() }; }
getGuilds() { return [...this.client.guilds.cache.values()].map((g) => ({ id: g.id, name: g.name })); }
getGuildChannels(guildId: string) { const guild = this.client.guilds.cache.get(guildId); if (!guild) return []; const results: { id: string; name: string; type: string; category: string | null }[] = []; for (const channel of guild.channels.cache.values()) { if (!channel.isTextBased?.() && !channel.isVoiceBased?.()) continue; const type = channel.isVoiceBased?.() ? "voice" : "text"; const category = (channel as { parent?: { name?: string } | null }).parent?.name ?? null; results.push({ id: channel.id, name: channel.name, type, category }); } results.sort((a, b) => (a.category ?? "").localeCompare(b.category ?? "") || a.name.localeCompare(b.name)); return results; }
purgeGuildMemoryRuntime(guildId: string) { const normalizedGuildId = String(guildId || "").trim(); if (!normalizedGuildId) return false;
const session = this.voiceSessionManager.getSession(normalizedGuildId);
if (!session) return false;
session.factProfiles = new Map();
session.guildFactProfile = null;
session.behavioralFactCache = null;
session.conversationHistoryCaches = null;
if (session.warmMemory?.snapshot) {
session.warmMemory.snapshot = null;
}
this.voiceSessionManager.primeSessionFactProfiles(session);
return true;
}
async requestVoiceJoinFromDashboard({ guildId = null, requesterUserId = null, textChannelId = null, source = "dashboard_voice_tab" } = {}) { return await requestVoiceJoinFromDashboard(this.toVoiceCoordinationRuntime(), { guildId, requesterUserId, textChannelId, source }); }
async applyRuntimeSettings(nextSettings = null) { const settings = nextSettings || this.store.getSettings(); await this.voiceSessionManager.reconcileSettings(settings); }
async reloadOAuthProviders() { return this.llm.reloadOAuthProviders(); }
async ingestVoiceStreamFrame({ guildId, streamerUserId = null, mimeType = "image/jpeg", dataBase64 = "", source = "api_stream_ingest" }) { const settings = this.store.getSettings(); return await this.voiceSessionManager.ingestStreamFrame({ guildId, streamerUserId, mimeType, dataBase64, source, settings }); }
getReplyQueuePendingCount() { let total = 0; for (const queue of this.replyQueues.values()) { total += queue.length; } return total; }
enqueueReplyJob({ message, source, forceRespond = false, addressSignal = null, performanceSeed = null }) { if (!message?.id || !message?.channelId) return false;
const messageId = String(message.id);
if (!messageId) return false;
if (this.replyQueuedMessageIds.has(messageId)) return false;
if (this.store.hasTriggeredResponse(messageId)) return false;
const channelId = String(message.channelId);
const queue = this.replyQueues.get(channelId) || [];
if (queue.length >= REPLY_QUEUE_MAX_PER_CHANNEL) {
this.store.logAction({
kind: "bot_error",
guildId: message.guildId,
channelId: message.channelId,
messageId,
userId: message.author?.id || null,
content: `reply_queue_overflow: limit=${REPLY_QUEUE_MAX_PER_CHANNEL}`
});
return false;
}
queue.push({
message,
source: source || "message_event",
forceRespond: Boolean(forceRespond),
addressSignal,
performanceSeed: normalizeReplyPerformanceSeed({
triggerMessageCreatedAtMs: message?.createdTimestamp,
queuedAtMs: Date.now(),
ingestMs: performanceSeed?.ingestMs
}),
attempts: 0
});
this.replyQueues.set(channelId, queue);
this.replyQueuedMessageIds.add(messageId);
this.processReplyQueue(channelId).catch((error) => {
this.store.logAction({
kind: "bot_error",
guildId: message.guildId,
channelId: message.channelId,
messageId,
userId: message.author?.id || null,
content: `reply_queue_worker: ${String(error?.message || error)}`
});
});
return true;
}
getReplyQueueWaitMs(settings) { return getReplyQueueWaitMs(this.toQueueGatewayRuntime(), settings); }
dequeueReplyJob(channelId) { return dequeueReplyJob(this.toQueueGatewayRuntime(), channelId); }
dequeueReplyBurst(channelId, settings) { return dequeueReplyBurst(this.toQueueGatewayRuntime(), channelId, settings); }
requeueReplyJobs(channelId, jobs) { return requeueReplyJobs(this.toQueueGatewayRuntime(), channelId, jobs); }
async processReplyQueue(channelId) { return await processReplyQueue(this.toQueueGatewayRuntime(), channelId); }
async ensureGatewayHealthy() { return await ensureGatewayHealthy(this.toQueueGatewayRuntime()); }
scheduleReconnect(reason, delayMs) { return scheduleReconnect(this.toQueueGatewayRuntime(), reason, delayMs); }
async reconnectGateway(reason) { return await reconnectGateway(this.toQueueGatewayRuntime(), reason); }
clearQueuedReplies(channelId) { const normalizedChannelId = String(channelId || "").trim(); if (!normalizedChannelId) return 0; const queue = this.replyQueues.get(normalizedChannelId); if (!Array.isArray(queue) || queue.length <= 0) return 0; for (const job of queue) { const queuedMessageId = String(job?.message?.id || "").trim(); if (queuedMessageId) { this.replyQueuedMessageIds.delete(queuedMessageId); } } this.replyQueues.delete(normalizedChannelId); return queue.length; }
dispatchBackgroundCodeTask({ session, task, role, guildId, channelId, userId = null, triggerMessageId = null, source = "reply_tool_code_task", progressReports }: { session: import("./agents/subAgentSession.ts").SubAgentSession; task: string; role: import("./agents/codeAgent.ts").CodeAgentRole; guildId: string; channelId: string; userId?: string | null; triggerMessageId?: string | null; source?: string; progressReports?: { enabled?: boolean; intervalMs?: number; maxReportsPerTask?: number; }; }) { const scopeKey = buildCodeTaskScopeKey({ guildId, channelId }); return this.backgroundTaskRunner.dispatch({ session, input: task, role, guildId, channelId, userId, triggerMessageId, scopeKey, source, progressReports: { enabled: progressReports?.enabled !== false, intervalMs: Number(progressReports?.intervalMs) || 60_000, maxReportsPerTask: Number(progressReports?.maxReportsPerTask) || 5 }, onProgress: async (taskSnapshot, recentEvents) => { await this.deliverAsyncTaskProgress(taskSnapshot, recentEvents); }, onComplete: async (taskSnapshot) => { await this.deliverAsyncTaskResult(taskSnapshot); } }); }
async deliverAsyncTaskResult(task: BackgroundTask) { if (!task?.channelId || !task?.guildId) return false; const mode = task.status === "cancelled" ? "cancelled" : "completion"; const durationMs = Math.max(0, Number(task.completedAt || Date.now()) - Number(task.startedAt || Date.now())); const rawResultText = String(task.result?.text || "").trim(); const fallbackResultText = String(task.errorMessage || "").trim(); const resultText = (rawResultText || fallbackResultText || "Task finished with no text output.").slice(0, 6000); const promptText = buildCodeTaskResultPrompt({ mode, sessionId: task.sessionId, role: task.role, status: task.status, durationMs, costUsd: Number(task.result?.costUsd || 0), resultText, filesTouched: task.progress.fileEdits, triggerMessageId: task.triggerMessageId }); const source = String(task.source || "") .trim() .toLowerCase(); const fromVoiceRealtime = source.startsWith("voice_realtime_tool_code_task"); if (fromVoiceRealtime) { const deliveredToVoiceRealtime = this.voiceSessionManager.requestRealtimeCodeTaskFollowup({ guildId: task.guildId, channelId: task.channelId, prompt: promptText, userId: task.userId, source: mode === "cancelled" ? "voice_realtime_code_task_cancelled_followup" : "voice_realtime_code_task_result_followup" }); if (deliveredToVoiceRealtime) { return true; } } return await this.enqueueCodeTaskSyntheticEvent({ task, source: "code_task_result", promptText, forceRespond: true }); }
async deliverAsyncTaskProgress(task: BackgroundTask, recentEvents: import("./agents/subAgentSession.ts").SubAgentProgressEvent[]) { if (!task?.channelId || !task?.guildId) return false; if (!Array.isArray(recentEvents) || recentEvents.length <= 0) return false; const elapsedMs = Math.max(0, Date.now() - Number(task.startedAt || Date.now())); const promptText = buildCodeTaskResultPrompt({ mode: "progress", sessionId: task.sessionId, role: task.role, status: task.status, durationMs: elapsedMs, costUsd: Number(task.result?.costUsd || 0), filesTouched: task.progress.fileEdits, triggerMessageId: task.triggerMessageId, recentEvents: recentEvents.map((event) => ({ summary: event.summary })) }); return await this.enqueueCodeTaskSyntheticEvent({ task, source: "code_task_progress", promptText, forceRespond: true }); }
private async enqueueCodeTaskSyntheticEvent({ task, source, promptText, forceRespond }: { task: BackgroundTask; source: string; promptText: string; forceRespond: boolean; }) { const channel = this.client.channels.cache.get(String(task.channelId || "")); if (!isSendableChannel(channel)) { this.store.logAction({ kind: "bot_error", guildId: task.guildId || null, channelId: task.channelId || null, userId: task.userId || null, content: "code_task_synthetic_delivery_channel_unavailable", metadata: { taskId: task.id, sessionId: task.sessionId, source } }); return false; }
const guild = this.client.guilds.cache.get(String(task.guildId || ""));
if (!guild) {
this.store.logAction({
kind: "bot_error",
guildId: task.guildId || null,
channelId: task.channelId || null,
userId: task.userId || null,
content: "code_task_synthetic_delivery_guild_unavailable",
metadata: {
taskId: task.id,
sessionId: task.sessionId,
source
}
});
return false;
}
const requesterUserId = String(task.userId || this.client.user?.id || "system").trim() || "system";
const requesterNameFromGuild = guild.members?.cache?.get(requesterUserId)?.displayName;
const requesterNameFromUser = this.client.users?.cache?.get(requesterUserId)?.username;
const requesterName = String(requesterNameFromGuild || requesterNameFromUser || "Requester");
const syntheticId = `${source}-${task.id}-${Date.now()}`;
const syntheticTimestamp = Date.now();
this.store.recordMessage({
messageId: syntheticId,
createdAt: syntheticTimestamp,
guildId: String(task.guildId || ""),
channelId: String(task.channelId || ""),
authorId: requesterUserId,
authorName: requesterName,
isBot: false,
content: promptText,
referencedMessageId: task.triggerMessageId || null
});
const syntheticMessage = {
id: syntheticId,
channelId: String(task.channelId || ""),
guildId: String(task.guildId || ""),
guild,
channel,
content: promptText,
createdTimestamp: syntheticTimestamp,
author: {
id: requesterUserId,
username: requesterName,
bot: false
},
member: {
displayName: requesterName
},
mentions: { users: new Map(), repliedUser: null },
reference: task.triggerMessageId
? { messageId: task.triggerMessageId }
: null,
referencedMessage: null,
attachments: new Map()
};
const queued = this.enqueueReplyJob({
source,
message: syntheticMessage,
forceRespond
});
this.store.logAction({
kind: queued ? "text_runtime" : "bot_error",
guildId: String(task.guildId || ""),
channelId: String(task.channelId || ""),
userId: requesterUserId,
content: queued ? "code_task_synthetic_delivery_queued" : "code_task_synthetic_delivery_queue_rejected",
metadata: {
taskId: task.id,
sessionId: task.sessionId,
source,
forceRespond: Boolean(forceRespond),
syntheticMessageId: syntheticId
}
});
return queued;
}
async acknowledgeTextCancellation({ message, settings, cancelText, cancelledReplyCount = 0, cancelledQueuedReplyCount = 0, browserCancelled = false }) { const acknowledgement = await generateTextCancelAcknowledgement({ llm: this.llm, settings, guildId: message.guildId || null, channelId: message.channelId || null, userId: message.author?.id || null, messageId: message.id || null, authorName: message.member?.displayName || message.author?.username || "someone", cancelText, cancelledReplyCount, cancelledQueuedReplyCount, browserCancelled });
if (acknowledgement) {
try {
const sent = await message.reply({
content: acknowledgement,
allowedMentions: { repliedUser: false }
});
this.lastBotMessageAt = Date.now();
const botUserId = String(this.client.user?.id || "").trim();
if (botUserId && sent?.id) {
this.store.recordMessage({
messageId: sent.id,
createdAt: sent.createdTimestamp,
guildId: sent.guildId,
channelId: sent.channelId,
authorId: botUserId,
authorName: getBotName(settings),
isBot: true,
content: composeMessageContentForHistory(
sent as Parameters<typeof composeMessageContentForHistory>[0],
acknowledgement
),
referencedMessageId: message.id
});
}
return true;
} catch (error) {
this.store.logAction({ kind: "bot_error", guildId: message.guildId, channelId: message.channelId, userId: message.author.id, content: "text_cancel_acknowledgement_failed", metadata: { error: String(error instanceof Error ? error.message : error) } });
}
}
try {
await message.react(TEXT_CANCEL_FALLBACK_REACTION);
return true;
} catch (error) {
this.store.logAction({ kind: "bot_error", guildId: message.guildId, channelId: message.channelId, userId: message.author.id, content: "text_cancel_reaction_failed", metadata: { error: String(error instanceof Error ? error.message : error) } });
return false;
}
}
async handleMessage(message) { if (!message.channel || !message.author) return; if (isAppCommandInvocationMessage(message)) return;
const settings = this.store.getSettings();
const text = String(message.content || "").trim();
const recordedContent = composeMessageContentForHistory(
message as Parameters<typeof composeMessageContentForHistory>[0],
text
);
this.store.recordMessage({
messageId: message.id,
createdAt: message.createdTimestamp,
guildId: message.guildId,
channelId: message.channelId,
authorId: message.author.id,
authorName: message.member?.displayName || message.author.username,
isBot: message.author.bot,
content: recordedContent,
referencedMessageId: message.reference?.messageId
});
if (String(message.author.id) === String(this.client.user?.id || "")) return;
const isDmContext = !String(message.guildId || "").trim();
if (!isDmContext && !isChannelAllowed(settings, String(message.channelId))) return;
if (isUserBlocked(settings, String(message.author.id))) return;
if (isCancelIntent(text)) {
const replyScopeKey = buildTextReplyScopeKey({
guildId: message.guildId,
channelId: message.channelId
});
const cancelledReplyCount = this.activeReplies.abortAll(
replyScopeKey,
"User requested cancellation via text"
);
const browserScopeKey = buildBrowserTaskScopeKey({
guildId: message.guildId,
channelId: message.channelId
});
const browserCancelled = this.activeBrowserTasks.abort(
browserScopeKey,
"User requested cancellation via text"
);
const codeTaskScopeKey = buildCodeTaskScopeKey({
guildId: message.guildId,
channelId: message.channelId
});
const cancelledBackgroundCodeTaskCount = this.backgroundTaskRunner.cancelByScope(
codeTaskScopeKey,
"User requested cancellation via text"
);
const cancelledQueuedReplyCount = this.clearQueuedReplies(message.channelId);
if (
cancelledReplyCount > 0 ||
cancelledQueuedReplyCount > 0 ||
browserCancelled ||
cancelledBackgroundCodeTaskCount > 0
) {
await this.acknowledgeTextCancellation({
message,
settings,
cancelText: text,
cancelledReplyCount,
cancelledQueuedReplyCount,
browserCancelled
});
return;
}
}
const musicSelectionHandled = await this.voiceSessionManager.maybeHandleMusicTextSelectionRequest({
message,
settings
});
if (musicSelectionHandled) return;
const musicStopHandled = await this.voiceSessionManager.maybeHandleMusicTextStopRequest({
message,
settings
});
if (musicStopHandled) return;
const memorySettings = getMemorySettings(settings);
if (memorySettings.enabled) {
void this.memory.ingestMessage({
messageId: message.id,
authorId: message.author.id,
authorName: message.member?.displayName || message.author.username,
content: text,
settings,
trace: {
guildId: message.guildId,
channelId: message.channelId,
userId: message.author.id
}
}).catch((error) => {
this.store.logAction({
kind: "bot_error",
guildId: message.guildId,
channelId: message.channelId,
messageId: message.id,
userId: message.author.id,
content: `memory_ingest: ${String(error?.message || error)}`
});
});
}
const recentMessages = this.store.getRecentMessages(
message.channelId,
memorySettings.promptSlice.maxRecentMessages
);
const addressSignal = await getReplyAddressSignal(
{
botUserId: String(this.client.user?.id || "").trim(),
isDirectlyAddressed: (resolvedSettings, resolvedMessage) =>
this.isDirectlyAddressed(resolvedSettings, resolvedMessage)
},
settings,
message,
recentMessages
);
const replyChannelEligible = isReplyChannel(settings, String(message.channelId));
const replyAdmissionDecision = evaluateReplyAdmissionDecision({
botUserId: this.client.user?.id,
settings,
message,
recentMessages,
addressSignal,
isReplyChannel: replyChannelEligible,
triggerMessageId: message.id,
triggerAuthorId: message.author?.id || null,
triggerReferenceMessageId: message.reference?.messageId || null,
windowSize: UNSOLICITED_REPLY_CONTEXT_WINDOW
});
this.store.logAction({
kind: "text_runtime",
guildId: message.guildId,
channelId: message.channelId,
messageId: message.id,
userId: message.author.id,
content: "reply_admission_decision",
metadata: {
...buildRuntimeDecisionCorrelation({
botId: this.client.user?.id || null,
triggerMessageId: message.id,
source: "message_event",
stage: "admission",
allow: replyAdmissionDecision.allow,
reason: replyAdmissionDecision.reason
}),
isReplyChannel,
allowUnsolicitedReplies: replyAdmissionDecision.allowUnsolicitedReplies,
ambientReplyEagerness: Number(settings?.interaction?.activity?.ambientReplyEagerness || 0),
addressSignal: {
direct: Boolean(addressSignal.direct),
inferred: Boolean(addressSignal.inferred),
triggered: Boolean(addressSignal.triggered),
reason: String(addressSignal.reason || "llm_decides"),
confidence: Math.max(0, Math.min(1, Number(addressSignal.confidence) || 0)),
threshold: Math.max(0.4, Math.min(0.95, Number(addressSignal.threshold) || 0.62)),
confidenceSource: addressSignal.confidenceSource || "fallback"
},
attentionMode: replyAdmissionDecision.attentionState.mode,
attentionReason: replyAdmissionDecision.attentionState.reason,
recentReplyWindowActive: replyAdmissionDecision.attentionState.recentReplyWindowActive,
responseWindowSize: replyAdmissionDecision.attentionState.responseWindowSize,
latestBotMessageId: replyAdmissionDecision.attentionState.latestBotMessageId,
triggerReferenceMessageId: message.reference?.messageId || null,
recentMessageCount: Array.isArray(recentMessages) ? recentMessages.length : 0
}
});
if (!replyAdmissionDecision.allow) return;
this.enqueueReplyJob({
source: "message_event",
message,
addressSignal
});
}
/**
- Handle a new member joining the guild.
- Resolves a target text channel (reply channels first, then system channel),
- records a synthetic event message, and feeds it through the normal reply
- pipeline so the LLM can decide whether to greet. */ async handleMemberJoin(member) { if (!member?.guild || !member?.user) return; if (member.user.bot) return; if (String(member.user.id) === String(this.client.user?.id || "")) return;
const settings = this.store.getSettings();
if (!settings?.permissions?.replies?.allowReplies) return;
const displayName = member.displayName || member.user.username || "Someone";
const accountAge = member.user.createdAt
? Math.floor((Date.now() - member.user.createdAt.getTime()) / (1000 * 60 * 60 * 24))
: null;
const accountAgeNote = accountAge !== null && accountAge < 7
? " (brand new Discord account)"
: "";
const eventContent = `[SERVER EVENT: ${displayName} just joined the server${accountAgeNote}. This is not a chat message — it is a membership event. You may greet them naturally if it fits, or output [SKIP] if you have nothing to say.]`;
// Resolve target channel: prefer reply channels, then guild system channel,
// then first sendable channel in the guild.
const permissions = getReplyPermissions(settings);
const greetingCandidateIds = [
...(Array.isArray(permissions.replyChannelIds) ? permissions.replyChannelIds : []),
...(Array.isArray(permissions.discoveryChannelIds) ? permissions.discoveryChannelIds : [])
].map((id) => String(id).trim()).filter(Boolean);
let targetChannel = null;
for (const channelId of greetingCandidateIds) {
const channel = this.client.channels.cache.get(channelId);
if (isSendableChannel(channel) && isChannelAllowed(settings, channelId)) {
targetChannel = channel;
break;
}
}
if (!targetChannel && member.guild.systemChannelId) {
const sysChannel = this.client.channels.cache.get(member.guild.systemChannelId);
if (isSendableChannel(sysChannel) && isChannelAllowed(settings, member.guild.systemChannelId)) {
targetChannel = sysChannel;
}
}
if (!targetChannel) {
const fallback = member.guild.channels.cache
.filter((ch) => isSendableChannel(ch) && isChannelAllowed(settings, String(ch.id)))
.first();
if (fallback && isSendableChannel(fallback)) {
targetChannel = fallback;
}
}
if (!targetChannel) return;
// Build a synthetic pseudo-ID so the dedup checks in enqueueReplyJob pass.
const syntheticId = `member-join-${member.user.id}-${Date.now()}`;
// Record the event in message history so it appears in recent chat context.
this.store.recordMessage({
messageId: syntheticId,
createdAt: Date.now(),
guildId: member.guild.id,
channelId: targetChannel.id,
authorId: member.user.id,
authorName: displayName,
isBot: false,
content: eventContent,
referencedMessageId: null
});
// Build a minimal message-like object that the reply pipeline can consume.
const syntheticMessage = {
id: syntheticId,
channelId: targetChannel.id,
guildId: member.guild.id,
guild: member.guild,
channel: targetChannel,
content: eventContent,
createdTimestamp: Date.now(),
author: {
id: member.user.id,
username: member.user.username,
bot: false
},
member: {
displayName
},
mentions: { users: new Map(), repliedUser: null },
reference: null,
referencedMessage: null,
attachments: new Map()
};
this.store.logAction({
kind: "text_runtime",
guildId: member.guild.id,
channelId: targetChannel.id,
userId: member.user.id,
content: "member_join_event",
metadata: {
displayName,
targetChannelId: targetChannel.id,
syntheticId
}
});
// Feed through normal admission — the LLM decides whether to greet.
const recentMessages = this.store.getRecentMessages(targetChannel.id, 20);
const addressSignal = {
direct: false,
inferred: false,
triggered: false,
reason: "member_join_event",
confidence: 0,
threshold: 0.62,
confidenceSource: "fallback" as const
};
const replyChannelEligible = isReplyChannel(settings, String(targetChannel.id));
const replyAdmissionDecision = evaluateReplyAdmissionDecision({
botUserId: this.client.user?.id,
settings,
recentMessages,
addressSignal,
isReplyChannel: replyChannelEligible,
triggerMessageId: syntheticId,
triggerAuthorId: member.user.id,
triggerReferenceMessageId: null,
windowSize: UNSOLICITED_REPLY_CONTEXT_WINDOW
});
this.store.logAction({
kind: "text_runtime",
guildId: member.guild.id,
channelId: targetChannel.id,
userId: member.user.id,
content: "member_join_admission_decision",
metadata: {
allow: replyAdmissionDecision.allow,
reason: replyAdmissionDecision.reason,
isReplyChannel,
syntheticId
}
});
if (!replyAdmissionDecision.allow) return;
this.enqueueReplyJob({
source: "member_join_event",
message: syntheticMessage,
addressSignal
});
}
shouldSendAsReply({ isReplyChannel = false, shouldThreadReply = false, replyText = "" } = {}) { if (!shouldThreadReply) return false; const textLength = String(replyText || "").trim().length; const isShortReply = textLength > 0 && textLength <= 30; if (isShortReply) return chance(0.25); if (!isReplyChannel) return chance(0.82); return chance(0.55); }
shouldSkipSimulatedTypingDelay() { if (this.appConfig?.disableSimulatedTypingDelay === true) return true; return IS_TEST_PROCESS; }
getSimulatedTypingDelayMs(minMs, jitterMs) { if (this.shouldSkipSimulatedTypingDelay()) return 0; return minMs + Math.floor(Math.random() * jitterMs); }
async maybeReplyToMessage(message, settings, options: ReplyAttemptOptions = {}) { return await maybeReplyToMessagePipeline(this.toReplyPipelineRuntime(), message, settings, options); }
async maybeHandleStructuredAutomationIntent({ message, settings, replyDirective, generation, source, triggerMessageIds = [], addressing = null, performance = null, replyPrompts = null }) { if (!settings?.automations?.enabled) return false; const automationAction = replyDirective?.automationAction; const operation = String(automationAction?.operation || "").trim(); if (!operation) return false;
const result = await applyAutomationControlAction(
{
store: this.store,
client: this.client,
isChannelAllowed: (runtimeSettings, channelId) =>
isChannelAllowed(runtimeSettings, String(channelId)),
maybeRunAutomationCycle: () => maybeRunAutomationCycle(this.toAutomationEngineRuntime())
},
{
message,
settings,
automationAction
}
);
if (!result || typeof result !== "object" || !("handled" in result) || result.handled !== true) {
return false;
}
const resultDetailLines = "detailLines" in result && Array.isArray(result.detailLines)
? result.detailLines
: [];
const resultMetadata = "metadata" in result ? result.metadata : null;
const finalText = composeAutomationControlReply({
modelText: replyDirective?.text,
detailLines: resultDetailLines
});
if (!finalText || finalText === "[SKIP]") {
this.store.logAction({
kind: "bot_error",
guildId: message.guildId,
channelId: message.channelId,
messageId: message.id,
userId: this.client.user?.id || null,
content: "automation_control_reply_missing",
metadata: {
operation,
source,
automationControl: resultMetadata || null
}
});
return true;
}
const typingStartedAtMs = Date.now();
await message.channel.sendTyping();
const typingDelayMs = Math.max(0, Date.now() - typingStartedAtMs);
const sendStartedAtMs = Date.now();
const sent = await message.reply({
content: finalText,
allowedMentions: { repliedUser: false }
});
const sendMs = Math.max(0, Date.now() - sendStartedAtMs);
const memorySettings = getMemorySettings(settings);
this.lastBotMessageAt = Date.now();
this.store.recordMessage({
messageId: sent.id,
createdAt: sent.createdTimestamp,
guildId: sent.guildId,
channelId: sent.channelId,
authorId: this.client.user.id,
authorName: getBotName(settings),
isBot: true,
content: composeMessageContentForHistory(
sent as Parameters<typeof composeMessageContentForHistory>[0],
finalText
),
referencedMessageId: message.id
});
if (memorySettings.enabled && typeof this.memory?.ingestMessage === "function") {
void this.memory.ingestMessage({
messageId: sent.id,
authorId: this.client.user.id,
authorName: getBotName(settings),
content: finalText,
isBot: true,
settings,
trace: {
guildId: sent.guildId,
channelId: sent.channelId,
userId: this.client.user.id,
source: "text_reply_memory_ingest"
}
}).catch((error) => {
this.store.logAction({
kind: "bot_error",
guildId: sent.guildId,
channelId: sent.channelId,
messageId: sent.id,
userId: this.client.user.id,
content: `memory_text_reply_ingest: ${String(error?.message || error)}`
});
});
}
this.store.logAction({
kind: "sent_reply",
guildId: sent.guildId,
channelId: sent.channelId,
messageId: sent.id,
userId: this.client.user.id,
content: finalText,
metadata: {
triggerMessageId: message.id,
triggerMessageIds,
source,
sendAsReply: true,
canStandalonePost: isReplyChannel(settings, String(message.channelId))
|| isDiscoveryChannel(settings, String(message.channelId)),
addressing,
replyPrompts,
automationControl: resultMetadata || null,
llm: {
provider: generation?.provider || null,
model: generation?.model || null,
usage: generation?.usage || null,
costUsd: generation?.costUsd || 0
},
performance: finalizeReplyPerformanceSample({
performance,
actionKind: "sent_reply",
typingDelayMs,
sendMs
})
}
});
return true;
}
async maybeApplyReplyReaction({ message, settings, emojiOptions, emojiToken, generation, source, triggerMessageId, triggerMessageIds = [], addressing }) { const result = { requestedByModel: Boolean(emojiToken), used: false, emoji: null, blockedByPermission: false, blockedByHourlyCap: false, blockedByAllowedSet: false }; const normalized = normalizeReactionEmojiToken(emojiToken); if (!normalized) return result;
const permissions = getReplyPermissions(settings);
if (!permissions.allowReactions) {
return {
...result,
blockedByPermission: true
};
}
if (!this.canTakeAction("reacted", permissions.maxReactionsPerHour)) {
return {
...result,
blockedByHourlyCap: true
};
}
if (!emojiOptions.includes(normalized)) {
return {
...result,
blockedByAllowedSet: true,
emoji: normalized
};
}
try {
await message.react(normalized);
this.store.logAction({
kind: "reacted",
guildId: message.guildId,
channelId: message.channelId,
messageId: message.id,
userId: this.client.user.id,
content: normalized,
metadata: {
source,
triggerMessageId,
triggerMessageIds,
addressing,
reason: "reply_directive",
llm: {
provider: generation.provider,
model: generation.model,
usage: generation.usage,
costUsd: generation.costUsd
}
}
});
return {
...result,
used: true,
emoji: normalized
};
} catch {
return {
...result,
emoji: normalized
};
}
}
logSkippedReply({ message, source, triggerMessageIds = [], addressSignal, generation = null, usedWebSearchFollowup = false, reason, reaction, screenShareOffer = null, performance = null, prompts = null, extraMetadata = null }) { const llmMetadata = generation ? { provider: generation.provider, model: generation.model, usage: generation.usage, costUsd: generation.costUsd, usedWebSearchFollowup } : null; this.store.logAction({ kind: "reply_skipped", guildId: message.guildId, channelId: message.channelId, messageId: message.id, userId: this.client.user.id, content: reason, metadata: { triggerMessageId: message.id, triggerMessageIds, source, addressing: addressSignal, replyPrompts: prompts, reaction, screenShareOffer, llm: llmMetadata, performance: finalizeReplyPerformanceSample({ performance, actionKind: "reply_skipped" }), ...(extraMetadata && typeof extraMetadata === "object" ? extraMetadata : {}) } }); }
canTalkNow(settings) { const activity = getActivitySettings(settings); const elapsed = Date.now() - this.lastBotMessageAt; return elapsed >= activity.minSecondsBetweenMessages * 1000; }
canTakeAction(kind, maxPerHour) { const since = new Date(Date.now() - 60 * 60 * 1000).toISOString(); const count = this.store.countActionsSince(kind, since); return count < maxPerHour; }
canSendMessage(maxPerHour) { const since = new Date(Date.now() - 60 * 60 * 1000).toISOString(); const sentReplies = this.store.countActionsSince("sent_reply", since); const sentMessages = this.store.countActionsSince("sent_message", since); const initiativePosts = this.store.countActionsSince("initiative_post", since); return sentReplies + sentMessages + initiativePosts < maxPerHour; }
isNonPrivateReplyEligibleChannel(channel) { if (!channel || typeof channel !== "object") return false; if (!channel.isTextBased?.() || typeof channel.send !== "function") return false; if (channel.isDMBased?.()) return false; if (!String(channel.guildId || channel.guild?.id || "").trim()) return false; if (channel.isThread?.() && Boolean(channel.private)) return false; return true; }
isDirectlyAddressed(_settings, message) { const mentioned = message.mentions?.users?.has(this.client.user.id); const isReplyToBot = message.mentions?.repliedUser?.id === this.client.user.id; return Boolean(mentioned || isReplyToBot); }
async runStartupTasks() { if (this.isStopping) return; if (this.startupTasksRan) return; this.startupTasksRan = true;
const settings = this.store.getSettings();
await runStartupCatchup(
{
botUserId: String(this.client.user?.id || "").trim(),
store: this.store,
getStartupScanChannels: (runtimeSettings) => this.getStartupScanChannels(runtimeSettings),
hydrateRecentMessages: (channel, limit) => this.hydrateRecentMessages(channel, limit),
isChannelAllowed: (runtimeSettings, channelId) =>
isChannelAllowed(runtimeSettings, String(channelId)),
isUserBlocked: (runtimeSettings, userId) => isUserBlocked(runtimeSettings, String(userId)),
getReplyAddressSignal: (runtimeSettings, message, recentMessages) =>
getReplyAddressSignal(
{
botUserId: this.toBotContext().botUserId,
isDirectlyAddressed: (resolvedSettings, resolvedMessage) =>
this.isDirectlyAddressed(resolvedSettings, resolvedMessage)
},
runtimeSettings,
message,
recentMessages
),
hasStartupFollowupAfterMessage: (payload) =>
hasStartupFollowupAfterMessage({
botUserId: this.client.user?.id,
...payload
}),
enqueueReplyJob: (payload) => this.enqueueReplyJob(payload)
},
settings
);
await maybeRunAutomationCycle(this.toAutomationEngineRuntime());
// Run an ambient initiative cycle on startup to catch unanswered conversations
await maybeRunInitiativeCycle(this.toInitiativeRuntime());
// Catch up on any missed reflections from past days
const startupSettings = this.store.getSettings();
const startupMemory = getMemorySettings(startupSettings);
if (startupMemory.enabled && startupMemory.reflection?.enabled) {
await this.memory.runDailyReflection(startupSettings);
}
}
async maybeRunReflection() { const settings = this.store.getSettings(); const memory = getMemorySettings(settings); if (!memory.enabled || !memory.reflection?.enabled) return;
const hour = Number(memory.reflection.hour ?? 4);
const minute = Number(memory.reflection.minute ?? 0);
const schedule = { kind: "daily" as const, hour, minute };
if (!this.nextReflectionRunAt) {
this.nextReflectionRunAt = resolveInitialNextRunAt({ schedule, nowMs: Date.now() });
}
if (!this.nextReflectionRunAt) return;
if (Date.now() < Date.parse(this.nextReflectionRunAt)) return;
try {
await this.memory.runDailyReflection(settings);
} finally {
this.nextReflectionRunAt = resolveFollowingNextRunAt({
schedule,
runFinishedMs: Date.now()
});
}
}
getStartupScanChannels(settings) { const permissions = getReplyPermissions(settings); const channels = []; const seen = new Set(); let explicitSelectedCount = 0; let replyEligibleSelectedCount = 0; let skippedNotSendableCount = 0; let skippedNotAllowedCount = 0; let skippedDuplicateCount = 0; const startupScanMetadata = { replyChannelCount: Array.isArray(permissions.replyChannelIds) ? permissions.replyChannelIds.length : 0, discoveryChannelCount: Array.isArray(permissions.discoveryChannelIds) ? permissions.discoveryChannelIds.length : 0, allowedChannelCount: Array.isArray(permissions.allowedChannelIds) ? permissions.allowedChannelIds.length : 0 }; const logStartupScan = (content, metadata = {}) => { this.store.logAction({ kind: "bot_lifecycle", userId: this.client.user?.id || null, content, metadata: { ...startupScanMetadata, ...metadata } }); };
const explicit = [
...permissions.replyChannelIds,
...permissions.discoveryChannelIds,
...permissions.allowedChannelIds
];
for (const id of explicit) {
const channel = this.client.channels.cache.get(String(id));
if (!isSendableChannel(channel)) {
skippedNotSendableCount += 1;
continue;
}
if (seen.has(channel.id)) {
skippedDuplicateCount += 1;
continue;
}
seen.add(channel.id);
channels.push(channel);
explicitSelectedCount += 1;
}
for (const guild of this.client.guilds.cache.values()) {
const guildChannels = Array.from(guild.channels.cache?.values?.() || []);
for (const channel of guildChannels) {
if (!isSendableChannel(channel)) {
skippedNotSendableCount += 1;
continue;
}
if (seen.has(channel.id)) {
skippedDuplicateCount += 1;
continue;
}
if (!isChannelAllowed(settings, String(channel.id))) {
skippedNotAllowedCount += 1;
continue;
}
seen.add(channel.id);
channels.push(channel);
replyEligibleSelectedCount += 1;
}
}
logStartupScan("startup_catchup_channel_scan_complete", {
selectedChannelCount: channels.length,
explicitSelectedCount,
replyEligibleSelectedCount,
skippedNotSendableCount,
skippedNotAllowedCount,
skippedDuplicateCount
});
return channels;
}
async hydrateRecentMessages(channel, limit) { try { const fetched = await channel.messages.fetch({ limit }); const sorted = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp);
for (const message of sorted) {
this.store.recordMessage({
messageId: message.id,
createdAt: message.createdTimestamp,
guildId: message.guildId,
channelId: message.channelId,
authorId: message.author?.id || "unknown",
authorName: message.member?.displayName || message.author?.username || "unknown",
isBot: Boolean(message.author?.bot),
content: composeMessageContentForHistory(
message as Parameters<typeof composeMessageContentForHistory>[0],
String(message.content || "").trim()
),
referencedMessageId: message.reference?.messageId
});
}
return sorted;
} catch {
return [];
}
}
getEmojiHints(guild) {
if (!guild?.emojis?.cache) return [];
const custom = guild.emojis.cache
.map((emoji) => (emoji.animated ? <a:${emoji.name}:${emoji.id}> : <:${emoji.name}:${emoji.id}>))
.slice(0, 24);
return custom;
}
getReactionEmojiOptions(guild) { if (!guild?.emojis?.cache) return []; return guild.emojis.cache.map((emoji) => emoji.identifier).slice(0, 24); }
async syncMessageSnapshotFromReaction(reaction) { await syncMessageSnapshotFromReaction(this.toBotContext(), reaction); }
async recordReactionHistoryEvent(reaction, user) { await recordReactionHistoryEvent(this.toBotContext(), reaction, user); }
async syncMessageSnapshot(message) { await syncMessageSnapshot(this.toBotContext(), message); }
}
