diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8b3b0f667..d5daba859d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow. - Discord/Agent-scoped media roots: pass `mediaLocalRoots` through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow. - Discord/slash command handling: intercept text-based slash commands in channels, register plugin commands as native, and send fallback acknowledgments for empty slash runs so interactions do not hang. Thanks @thewilloftheshadow. +- Discord/thread session lifecycle: reset thread-scoped sessions when a thread is archived so reopening a thread starts fresh without deleting transcript history. Thanks @thewilloftheshadow. - Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow. - Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow. - Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow. diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index bf6f19c7e6a..71d7cfbddf9 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -5,8 +5,10 @@ import { MessageReactionAddListener, MessageReactionRemoveListener, PresenceUpdateListener, + ThreadUpdateListener, type User, } from "@buape/carbon"; +import type { OpenClawConfig } from "../../config/config.js"; import { danger, logVerbose } from "../../globals.js"; import { formatDurationSeconds } from "../../infra/format-time/format-duration.ts"; import { enqueueSystemEvent } from "../../infra/system-events.js"; @@ -30,6 +32,8 @@ import { import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js"; import { resolveDiscordChannelInfo } from "./message-utils.js"; import { setPresence } from "./presence-cache.js"; +import { isThreadArchived } from "./thread-bindings.discord-api.js"; +import { closeDiscordThreadSessions } from "./thread-session-close.js"; type LoadedConfig = ReturnType; type RuntimeEnv = import("../../runtime.js").RuntimeEnv; @@ -623,3 +627,49 @@ export class DiscordPresenceListener extends PresenceUpdateListener { } } } + +type ThreadUpdateEvent = Parameters[0]; + +export class DiscordThreadUpdateListener extends ThreadUpdateListener { + constructor( + private cfg: OpenClawConfig, + private accountId: string, + private logger?: Logger, + ) { + super(); + } + + async handle(data: ThreadUpdateEvent) { + await runDiscordListenerWithSlowLog({ + logger: this.logger, + listener: this.constructor.name, + event: this.type, + run: async () => { + // Discord only fires THREAD_UPDATE when a field actually changes, so + // `thread_metadata.archived === true` in this payload means the thread + // just transitioned to the archived state. + if (!isThreadArchived(data)) { + return; + } + const threadId = "id" in data && typeof data.id === "string" ? data.id : undefined; + if (!threadId) { + return; + } + const logger = this.logger ?? discordEventQueueLog; + logger.info("Discord thread archived — resetting session", { threadId }); + const count = await closeDiscordThreadSessions({ + cfg: this.cfg, + accountId: this.accountId, + threadId, + }); + if (count > 0) { + logger.info("Discord thread sessions reset after archival", { threadId, count }); + } + }, + onError: (err) => { + const logger = this.logger ?? discordEventQueueLog; + logger.error(danger(`discord thread-update handler failed: ${String(err)}`)); + }, + }); + } +} diff --git a/src/discord/monitor/provider.test.ts b/src/discord/monitor/provider.test.ts index 351661a8a9c..8481b5356f6 100644 --- a/src/discord/monitor/provider.test.ts +++ b/src/discord/monitor/provider.test.ts @@ -230,6 +230,7 @@ vi.mock("./listeners.js", () => ({ DiscordPresenceListener: class DiscordPresenceListener {}, DiscordReactionListener: class DiscordReactionListener {}, DiscordReactionRemoveListener: class DiscordReactionRemoveListener {}, + DiscordThreadUpdateListener: class DiscordThreadUpdateListener {}, registerDiscordListener: vi.fn(), })); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index bbfda2202fc..9ed99778d3c 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -66,6 +66,7 @@ import { DiscordPresenceListener, DiscordReactionListener, DiscordReactionRemoveListener, + DiscordThreadUpdateListener, registerDiscordListener, } from "./listeners.js"; import { createDiscordMessageHandler } from "./message-handler.js"; @@ -642,6 +643,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { new DiscordReactionRemoveListener(reactionListenerOptions), ); + registerDiscordListener( + client.listeners, + new DiscordThreadUpdateListener(cfg, account.accountId, logger), + ); + if (discordCfg.intents?.presence) { registerDiscordListener( client.listeners, diff --git a/src/discord/monitor/thread-session-close.test.ts b/src/discord/monitor/thread-session-close.test.ts new file mode 100644 index 00000000000..292d66889cf --- /dev/null +++ b/src/discord/monitor/thread-session-close.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => { + const updateSessionStore = vi.fn(); + const resolveStorePath = vi.fn(() => "/tmp/openclaw-sessions.json"); + return { updateSessionStore, resolveStorePath }; +}); + +vi.mock("../../config/sessions.js", () => ({ + updateSessionStore: hoisted.updateSessionStore, + resolveStorePath: hoisted.resolveStorePath, +})); + +const { closeDiscordThreadSessions } = await import("./thread-session-close.js"); + +function setupStore(store: Record) { + hoisted.updateSessionStore.mockImplementation( + async (_storePath: string, mutator: (s: typeof store) => unknown) => mutator(store), + ); +} + +const THREAD_ID = "999"; +const OTHER_ID = "111"; + +const MATCHED_KEY = `agent:main:discord:channel:${THREAD_ID}`; +const UNMATCHED_KEY = `agent:main:discord:channel:${OTHER_ID}`; + +describe("closeDiscordThreadSessions", () => { + beforeEach(() => { + hoisted.updateSessionStore.mockClear(); + hoisted.resolveStorePath.mockClear(); + hoisted.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions.json"); + }); + + it("resets updatedAt to 0 for sessions whose key contains the threadId", async () => { + const store = { + [MATCHED_KEY]: { updatedAt: 1_700_000_000_000 }, + [UNMATCHED_KEY]: { updatedAt: 1_700_000_000_001 }, + }; + setupStore(store); + + const count = await closeDiscordThreadSessions({ + cfg: {}, + accountId: "default", + threadId: THREAD_ID, + }); + + expect(count).toBe(1); + expect(store[MATCHED_KEY].updatedAt).toBe(0); + expect(store[UNMATCHED_KEY].updatedAt).toBe(1_700_000_000_001); + }); + + it("returns 0 and leaves store unchanged when no session matches", async () => { + const store = { + [UNMATCHED_KEY]: { updatedAt: 1_700_000_000_001 }, + }; + setupStore(store); + + const count = await closeDiscordThreadSessions({ + cfg: {}, + accountId: "default", + threadId: THREAD_ID, + }); + + expect(count).toBe(0); + expect(store[UNMATCHED_KEY].updatedAt).toBe(1_700_000_000_001); + }); + + it("resets all matching sessions when multiple keys contain the threadId", async () => { + const keyA = `agent:main:discord:channel:${THREAD_ID}`; + const keyB = `agent:work:discord:channel:${THREAD_ID}`; + const keyC = `agent:main:discord:channel:${OTHER_ID}`; + const store = { + [keyA]: { updatedAt: 1_000 }, + [keyB]: { updatedAt: 2_000 }, + [keyC]: { updatedAt: 3_000 }, + }; + setupStore(store); + + const count = await closeDiscordThreadSessions({ + cfg: {}, + accountId: "default", + threadId: THREAD_ID, + }); + + expect(count).toBe(2); + expect(store[keyA].updatedAt).toBe(0); + expect(store[keyB].updatedAt).toBe(0); + expect(store[keyC].updatedAt).toBe(3_000); + }); + + it("does not match a key that contains the threadId as a substring of a longer snowflake", async () => { + const longerSnowflake = `${THREAD_ID}00`; + const noMatchKey = `agent:main:discord:channel:${longerSnowflake}`; + const store = { + [noMatchKey]: { updatedAt: 9_999 }, + }; + setupStore(store); + + const count = await closeDiscordThreadSessions({ + cfg: {}, + accountId: "default", + threadId: THREAD_ID, + }); + + expect(count).toBe(0); + expect(store[noMatchKey].updatedAt).toBe(9_999); + }); + + it("matching is case-insensitive for the session key", async () => { + const uppercaseKey = `agent:main:discord:channel:${THREAD_ID.toUpperCase()}`; + const store = { + [uppercaseKey]: { updatedAt: 5_000 }, + }; + setupStore(store); + + const count = await closeDiscordThreadSessions({ + cfg: {}, + accountId: "default", + threadId: THREAD_ID.toLowerCase(), + }); + + expect(count).toBe(1); + expect(store[uppercaseKey].updatedAt).toBe(0); + }); + + it("returns 0 immediately when threadId is empty without touching the store", async () => { + const count = await closeDiscordThreadSessions({ + cfg: {}, + accountId: "default", + threadId: " ", + }); + + expect(count).toBe(0); + expect(hoisted.updateSessionStore).not.toHaveBeenCalled(); + }); + + it("resolves the store path using cfg.session.store and accountId", async () => { + const store = {}; + setupStore(store); + + await closeDiscordThreadSessions({ + cfg: { session: { store: "/custom/path/sessions.json" } }, + accountId: "my-bot", + threadId: THREAD_ID, + }); + + expect(hoisted.resolveStorePath).toHaveBeenCalledWith("/custom/path/sessions.json", { + agentId: "my-bot", + }); + }); +}); diff --git a/src/discord/monitor/thread-session-close.ts b/src/discord/monitor/thread-session-close.ts new file mode 100644 index 00000000000..1a5f6dd22f8 --- /dev/null +++ b/src/discord/monitor/thread-session-close.ts @@ -0,0 +1,59 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { resolveStorePath, updateSessionStore } from "../../config/sessions.js"; + +/** + * Marks every session entry in the store whose key contains {@link threadId} + * as "reset" by setting `updatedAt` to 0. + * + * This mirrors how the daily / idle session reset works: zeroing `updatedAt` + * makes `evaluateSessionFreshness` treat the session as stale on the next + * inbound message, so the bot starts a fresh conversation without deleting + * any on-disk transcript history. + */ +export async function closeDiscordThreadSessions(params: { + cfg: OpenClawConfig; + accountId: string; + threadId: string; +}): Promise { + const { cfg, accountId, threadId } = params; + + const normalizedThreadId = threadId.trim().toLowerCase(); + if (!normalizedThreadId) { + return 0; + } + + // Match when the threadId appears as a complete colon-separated segment. + // e.g. "999" must be followed by ":" (middle) or end-of-string (final). + // Using a regex avoids false-positives where one snowflake is a prefix of + // another (e.g. searching for "999" must not match ":99900"). + // + // Session key shapes: + // agent::discord:channel: + // agent::discord:channel::thread: + const segmentRe = new RegExp(`:${normalizedThreadId}(?::|$)`, "i"); + + function sessionKeyContainsThreadId(key: string): boolean { + return segmentRe.test(key); + } + + // Resolve the store file. We pass `accountId` as `agentId` here to mirror + // how other Discord subsystems resolve their per-account sessions stores. + const storePath = resolveStorePath(cfg.session?.store, { agentId: accountId }); + + let resetCount = 0; + + await updateSessionStore(storePath, (store) => { + for (const [key, entry] of Object.entries(store)) { + if (!entry || !sessionKeyContainsThreadId(key)) { + continue; + } + // Setting updatedAt to 0 signals that this session is stale. + // evaluateSessionFreshness will create a new session on the next message. + entry.updatedAt = 0; + resetCount += 1; + } + return resetCount; + }); + + return resetCount; +}