mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(discord): reset thread sessions on archive
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<typeof import("../../config/config.js").loadConfig>;
|
||||
type RuntimeEnv = import("../../runtime.js").RuntimeEnv;
|
||||
@@ -623,3 +627,49 @@ export class DiscordPresenceListener extends PresenceUpdateListener {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ThreadUpdateEvent = Parameters<ThreadUpdateListener["handle"]>[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)}`));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ vi.mock("./listeners.js", () => ({
|
||||
DiscordPresenceListener: class DiscordPresenceListener {},
|
||||
DiscordReactionListener: class DiscordReactionListener {},
|
||||
DiscordReactionRemoveListener: class DiscordReactionRemoveListener {},
|
||||
DiscordThreadUpdateListener: class DiscordThreadUpdateListener {},
|
||||
registerDiscordListener: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
152
src/discord/monitor/thread-session-close.test.ts
Normal file
152
src/discord/monitor/thread-session-close.test.ts
Normal file
@@ -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<string, { updatedAt: number }>) {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
59
src/discord/monitor/thread-session-close.ts
Normal file
59
src/discord/monitor/thread-session-close.ts
Normal file
@@ -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<number> {
|
||||
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:<agentId>:discord:channel:<threadId>
|
||||
// agent:<agentId>:discord:channel:<parentId>:thread:<threadId>
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user