mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 08:02:42 +00:00
Discord: thread bindings idle + max-age lifecycle (#27845) (thanks @osolmaz)
* refactor discord thread bindings to idle and max-age lifecycle * fix: migrate legacy thread binding expiry and reduce hot-path disk writes * refactor: remove remaining thread-binding ttl legacy paths * fix: harden thread-binding lifecycle persistence * Discord: fix thread binding types in message/reply paths * Infra: handle win32 unknown inode in file identity checks * Infra: relax win32 guarded-open identity checks * Config: migrate threadBindings ttlHours to idleHours * Revert "Infra: relax win32 guarded-open identity checks" This reverts commitde94126771. * Revert "Infra: handle win32 unknown inode in file identity checks" This reverts commit96fc5ddfb3. * Discord: re-read live binding state before sweep unbind * fix: add changelog note for thread binding lifecycle update (#27845) (thanks @osolmaz) --------- Co-authored-by: Onur Solmaz <onur@textcortex.com>
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import { parseDurationMs } from "../../cli/parse-duration.js";
|
||||
import { isRestartEnabled } from "../../config/commands.js";
|
||||
import {
|
||||
formatThreadBindingTtlLabel,
|
||||
formatThreadBindingDurationLabel,
|
||||
getThreadBindingManager,
|
||||
setThreadBindingTtlBySessionKey,
|
||||
resolveThreadBindingIdleTimeoutMs,
|
||||
resolveThreadBindingInactivityExpiresAt,
|
||||
resolveThreadBindingMaxAgeExpiresAt,
|
||||
resolveThreadBindingMaxAgeMs,
|
||||
setThreadBindingIdleTimeoutBySessionKey,
|
||||
setThreadBindingMaxAgeBySessionKey,
|
||||
} from "../../discord/monitor/thread-bindings.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js";
|
||||
@@ -17,7 +22,9 @@ import { persistSessionEntry } from "./commands-session-store.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
const SESSION_COMMAND_PREFIX = "/session";
|
||||
const SESSION_TTL_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]);
|
||||
const SESSION_DURATION_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]);
|
||||
const SESSION_ACTION_IDLE = "idle";
|
||||
const SESSION_ACTION_MAX_AGE = "max-age";
|
||||
|
||||
function isDiscordSurface(params: Parameters<CommandHandler>[0]): boolean {
|
||||
const channel =
|
||||
@@ -38,21 +45,21 @@ function resolveDiscordAccountId(params: Parameters<CommandHandler>[0]): string
|
||||
}
|
||||
|
||||
function resolveSessionCommandUsage() {
|
||||
return "Usage: /session ttl <duration|off> (example: /session ttl 24h)";
|
||||
return "Usage: /session idle <duration|off> | /session max-age <duration|off> (example: /session idle 24h)";
|
||||
}
|
||||
|
||||
function parseSessionTtlMs(raw: string): number {
|
||||
function parseSessionDurationMs(raw: string): number {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
throw new Error("missing ttl");
|
||||
throw new Error("missing duration");
|
||||
}
|
||||
if (SESSION_TTL_OFF_VALUES.has(normalized)) {
|
||||
if (SESSION_DURATION_OFF_VALUES.has(normalized)) {
|
||||
return 0;
|
||||
}
|
||||
if (/^\d+(?:\.\d+)?$/.test(normalized)) {
|
||||
const hours = Number(normalized);
|
||||
if (!Number.isFinite(hours) || hours < 0) {
|
||||
throw new Error("invalid ttl");
|
||||
throw new Error("invalid duration");
|
||||
}
|
||||
return Math.round(hours * 60 * 60 * 1000);
|
||||
}
|
||||
@@ -246,7 +253,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
const rest = normalized.slice(SESSION_COMMAND_PREFIX.length).trim();
|
||||
const tokens = rest.split(/\s+/).filter(Boolean);
|
||||
const action = tokens[0]?.toLowerCase();
|
||||
if (action !== "ttl") {
|
||||
if (action !== SESSION_ACTION_IDLE && action !== SESSION_ACTION_MAX_AGE) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: resolveSessionCommandUsage() },
|
||||
@@ -256,7 +263,9 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
if (!isDiscordSurface(params)) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ /session ttl is currently available for Discord thread-bound sessions." },
|
||||
reply: {
|
||||
text: "⚠️ /session idle and /session max-age are currently available for Discord thread-bound sessions.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -265,7 +274,9 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
if (!threadId) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ /session ttl must be run inside a focused Discord thread." },
|
||||
reply: {
|
||||
text: "⚠️ /session idle and /session max-age must be run inside a focused Discord thread.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,20 +297,59 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
};
|
||||
}
|
||||
|
||||
const ttlArgRaw = tokens.slice(1).join("");
|
||||
if (!ttlArgRaw) {
|
||||
const expiresAt = binding.expiresAt;
|
||||
if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > Date.now()) {
|
||||
const idleTimeoutMs = resolveThreadBindingIdleTimeoutMs({
|
||||
record: binding,
|
||||
defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(),
|
||||
});
|
||||
const idleExpiresAt = resolveThreadBindingInactivityExpiresAt({
|
||||
record: binding,
|
||||
defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(),
|
||||
});
|
||||
const maxAgeMs = resolveThreadBindingMaxAgeMs({
|
||||
record: binding,
|
||||
defaultMaxAgeMs: threadBindings.getMaxAgeMs(),
|
||||
});
|
||||
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
|
||||
record: binding,
|
||||
defaultMaxAgeMs: threadBindings.getMaxAgeMs(),
|
||||
});
|
||||
|
||||
const durationArgRaw = tokens.slice(1).join("");
|
||||
if (!durationArgRaw) {
|
||||
if (action === SESSION_ACTION_IDLE) {
|
||||
if (
|
||||
typeof idleExpiresAt === "number" &&
|
||||
Number.isFinite(idleExpiresAt) &&
|
||||
idleExpiresAt > Date.now()
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `ℹ️ Idle timeout active (${formatThreadBindingDurationLabel(idleTimeoutMs)}, next auto-unfocus at ${formatSessionExpiry(idleExpiresAt)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "ℹ️ Idle timeout is currently disabled for this focused session." },
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
typeof maxAgeExpiresAt === "number" &&
|
||||
Number.isFinite(maxAgeExpiresAt) &&
|
||||
maxAgeExpiresAt > Date.now()
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `ℹ️ Session TTL active (${formatThreadBindingTtlLabel(expiresAt - Date.now())}, auto-unfocus at ${formatSessionExpiry(expiresAt)}).`,
|
||||
text: `ℹ️ Max age active (${formatThreadBindingDurationLabel(maxAgeMs)}, hard auto-unfocus at ${formatSessionExpiry(maxAgeExpiresAt)}).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "ℹ️ Session TTL is currently disabled for this focused session." },
|
||||
reply: { text: "ℹ️ Max age is currently disabled for this focused session." },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,13 +357,15 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ Only ${binding.boundBy} can update session TTL for this thread.` },
|
||||
reply: {
|
||||
text: `⚠️ Only ${binding.boundBy} can update session lifecycle settings for this thread.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let ttlMs: number;
|
||||
let durationMs: number;
|
||||
try {
|
||||
ttlMs = parseSessionTtlMs(ttlArgRaw);
|
||||
durationMs = parseSessionDurationMs(durationArgRaw);
|
||||
} catch {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
@@ -321,40 +373,68 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
|
||||
};
|
||||
}
|
||||
|
||||
const updatedBindings = setThreadBindingTtlBySessionKey({
|
||||
targetSessionKey: binding.targetSessionKey,
|
||||
accountId,
|
||||
ttlMs,
|
||||
});
|
||||
const updatedBindings =
|
||||
action === SESSION_ACTION_IDLE
|
||||
? setThreadBindingIdleTimeoutBySessionKey({
|
||||
targetSessionKey: binding.targetSessionKey,
|
||||
accountId,
|
||||
idleTimeoutMs: durationMs,
|
||||
})
|
||||
: setThreadBindingMaxAgeBySessionKey({
|
||||
targetSessionKey: binding.targetSessionKey,
|
||||
accountId,
|
||||
maxAgeMs: durationMs,
|
||||
});
|
||||
if (updatedBindings.length === 0) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ Failed to update session TTL for the current binding." },
|
||||
};
|
||||
}
|
||||
|
||||
if (ttlMs <= 0) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `✅ Session TTL disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`,
|
||||
text:
|
||||
action === SESSION_ACTION_IDLE
|
||||
? "⚠️ Failed to update idle timeout for the current binding."
|
||||
: "⚠️ Failed to update max age for the current binding.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const expiresAt = updatedBindings[0]?.expiresAt;
|
||||
if (durationMs <= 0) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
action === SESSION_ACTION_IDLE
|
||||
? `✅ Idle timeout disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`
|
||||
: `✅ Max age disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextBinding = updatedBindings[0];
|
||||
const nextExpiry =
|
||||
action === SESSION_ACTION_IDLE
|
||||
? resolveThreadBindingInactivityExpiresAt({
|
||||
record: nextBinding,
|
||||
defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(),
|
||||
})
|
||||
: resolveThreadBindingMaxAgeExpiresAt({
|
||||
record: nextBinding,
|
||||
defaultMaxAgeMs: threadBindings.getMaxAgeMs(),
|
||||
});
|
||||
const expiryLabel =
|
||||
typeof expiresAt === "number" && Number.isFinite(expiresAt)
|
||||
? formatSessionExpiry(expiresAt)
|
||||
typeof nextExpiry === "number" && Number.isFinite(nextExpiry)
|
||||
? formatSessionExpiry(nextExpiry)
|
||||
: "n/a";
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `✅ Session TTL set to ${formatThreadBindingTtlLabel(ttlMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (auto-unfocus at ${expiryLabel}).`,
|
||||
text:
|
||||
action === SESSION_ACTION_IDLE
|
||||
? `✅ Idle timeout set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (next auto-unfocus at ${expiryLabel}).`
|
||||
: `✅ Max age set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (hard auto-unfocus at ${expiryLabel}).`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user