update: auto-reply

This commit is contained in:
Val Alexander 2026-03-16 05:52:07 -05:00
parent f285429952
commit e0541f772e
No known key found for this signature in database
8 changed files with 114 additions and 12 deletions

View File

@ -358,6 +358,7 @@
"@mariozechner/pi-tui": "0.57.1",
"@modelcontextprotocol/sdk": "1.27.1",
"@mozilla/readability": "^0.6.0",
"@pierre/diffs": "1.1.0",
"@sinclair/typebox": "0.34.48",
"@slack/bolt": "^4.6.0",
"@slack/web-api": "^7.15.0",

3
pnpm-lock.yaml generated
View File

@ -79,6 +79,9 @@ importers:
'@napi-rs/canvas':
specifier: ^0.1.89
version: 0.1.95
'@pierre/diffs':
specifier: 1.1.0
version: 1.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@sinclair/typebox':
specifier: 0.34.48
version: 0.34.48

View File

@ -150,22 +150,23 @@ export async function getReplyFromConfig(
typeof btwQuestion === "string" &&
shouldHandleTextCommands({
cfg,
surface: finalized.Surface,
surface: finalized.Surface ?? "",
commandSource: finalized.CommandSource,
});
const useBtwSideTurn = allowBtwSideTurn && typeof btwQuestion === "string";
if (useBtwSideTurn && !commandAuth.isAuthorizedSender) {
return undefined;
}
if (useBtwSideTurn && btwQuestion.length === 0) {
return { text: "⚙️ Usage: /btw <question>" };
}
if (useBtwSideTurn) {
finalized.Body = btwQuestion;
finalized.BodyForAgent = btwQuestion;
finalized.RawBody = btwQuestion;
finalized.CommandBody = btwQuestion;
finalized.BodyForCommands = btwQuestion;
const btwQuestionText = btwQuestion ?? "";
if (btwQuestionText.length === 0) {
return { text: "⚙️ Usage: /btw <question>" };
}
finalized.Body = btwQuestionText;
finalized.BodyForAgent = btwQuestionText;
finalized.RawBody = btwQuestionText;
finalized.CommandBody = btwQuestionText;
finalized.BodyForCommands = btwQuestionText;
}
if (!isFastTestEnv) {

View File

@ -20,6 +20,7 @@ import { logsHandlers } from "./server-methods/logs.js";
import { modelsHandlers } from "./server-methods/models.js";
import { nodePendingHandlers } from "./server-methods/nodes-pending.js";
import { nodeHandlers } from "./server-methods/nodes.js";
import { ptyHandlers } from "./server-methods/pty.js";
import { pushHandlers } from "./server-methods/push.js";
import { sendHandlers } from "./server-methods/send.js";
import { sessionsHandlers } from "./server-methods/sessions.js";

View File

@ -9,6 +9,7 @@ type GatewayClientMock = {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
options: { clientVersion?: string };
emitHello: (hello: Record<string, unknown>) => void;
emitClose: (info: {
code: number;
reason?: string;
@ -39,6 +40,7 @@ vi.mock("./gateway.ts", () => {
constructor(
private opts: {
clientVersion?: string;
onHello?: (hello: Record<string, unknown>) => void;
onClose?: (info: {
code: number;
reason: string;
@ -52,6 +54,9 @@ vi.mock("./gateway.ts", () => {
start: this.start,
stop: this.stop,
options: { clientVersion: this.opts.clientVersion },
emitHello: (hello) => {
this.opts.onHello?.(hello as never);
},
emitClose: (info) => {
this.opts.onClose?.({
code: info.code,
@ -158,6 +163,31 @@ describe("connectGateway", () => {
);
});
it("falls back to the main session when persisted session keys point at cron chats", () => {
const host = createHost();
host.sessionKey = "agent:main:cron:nightly-brief";
host.settings.sessionKey = "agent:main:cron:nightly-brief";
host.settings.lastActiveSessionKey = "cron:nightly-brief";
connectGateway(host);
const client = gatewayClientInstances[0];
expect(client).toBeDefined();
client.emitHello({
snapshot: {
sessionDefaults: {
mainSessionKey: "agent:main:main",
mainKey: "main",
defaultAgentId: "main",
},
},
});
expect(host.sessionKey).toBe("agent:main:main");
expect(host.settings.sessionKey).toBe("agent:main:main");
expect(host.settings.lastActiveSessionKey).toBe("agent:main:main");
});
it("ignores stale client onEvent callbacks after reconnect", () => {
const host = createHost();

View File

@ -118,6 +118,24 @@ export function resolveControlUiClientVersion(params: {
}
}
function isCronSessionKey(value: string | undefined): boolean {
const normalized = (value ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
if (normalized.startsWith("cron:")) {
return true;
}
if (!normalized.startsWith("agent:")) {
return false;
}
const parts = normalized.split(":").filter(Boolean);
if (parts.length < 3) {
return false;
}
return parts.slice(2).join(":").startsWith("cron:");
}
function normalizeSessionKeyForDefaults(
value: string | undefined,
defaults: SessionDefaultsSnapshot,
@ -130,6 +148,9 @@ function normalizeSessionKeyForDefaults(
if (!raw) {
return mainSessionKey;
}
if (isCronSessionKey(raw)) {
return mainSessionKey;
}
const mainKey = defaults.mainKey?.trim() || "main";
const defaultAgentId = defaults.defaultAgentId?.trim();
const isAlias =

View File

@ -1,6 +1,7 @@
const KEY = "openclaw.control.settings.v1";
const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1";
const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:";
const TOKEN_LOCAL_KEY_PREFIX = "openclaw.control.token.persisted.v1:";
type PersistedUiSettings = Omit<UiSettings, "token"> & { token?: never };
@ -11,6 +12,7 @@ import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts"
export type UiSettings = {
gatewayUrl: string;
token: string;
rememberGatewayAuth: boolean;
sessionKey: string;
lastActiveSessionKey: string;
theme: ThemeName;
@ -86,6 +88,10 @@ function tokenSessionKeyForGateway(gatewayUrl: string): string {
return `${TOKEN_SESSION_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`;
}
function tokenLocalKeyForGateway(gatewayUrl: string): string {
return `${TOKEN_LOCAL_KEY_PREFIX}${normalizeGatewayTokenScope(gatewayUrl)}`;
}
function loadSessionToken(gatewayUrl: string): string {
try {
const storage = getSessionStorage();
@ -119,12 +125,37 @@ function persistSessionToken(gatewayUrl: string, token: string) {
}
}
function loadRememberedToken(gatewayUrl: string): string {
try {
const token = localStorage.getItem(tokenLocalKeyForGateway(gatewayUrl)) ?? "";
return token.trim();
} catch {
return "";
}
}
function persistGatewayToken(gatewayUrl: string, token: string, remember: boolean) {
try {
const normalized = token.trim();
persistSessionToken(gatewayUrl, remember ? "" : normalized);
const localKey = tokenLocalKeyForGateway(gatewayUrl);
if (remember && normalized) {
localStorage.setItem(localKey, normalized);
} else {
localStorage.removeItem(localKey);
}
} catch {
// best-effort
}
}
export function loadSettings(): UiSettings {
const { pageUrl: pageDerivedUrl, effectiveUrl: defaultUrl } = deriveDefaultGatewayUrl();
const defaults: UiSettings = {
gatewayUrl: defaultUrl,
token: loadSessionToken(defaultUrl),
rememberGatewayAuth: false,
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
@ -152,10 +183,12 @@ export function loadSettings(): UiSettings {
(parsed as { theme?: unknown }).theme,
(parsed as { themeMode?: unknown }).themeMode,
);
const rememberGatewayAuth =
typeof parsed.rememberGatewayAuth === "boolean" ? parsed.rememberGatewayAuth : false;
const settings = {
gatewayUrl,
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: loadSessionToken(gatewayUrl),
token: rememberGatewayAuth ? loadRememberedToken(gatewayUrl) : loadSessionToken(gatewayUrl),
rememberGatewayAuth,
sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim()
@ -205,9 +238,10 @@ export function saveSettings(next: UiSettings) {
}
function persistSettings(next: UiSettings) {
persistSessionToken(next.gatewayUrl, next.token);
persistGatewayToken(next.gatewayUrl, next.token, next.rememberGatewayAuth);
const persisted: PersistedUiSettings = {
gatewayUrl: next.gatewayUrl,
rememberGatewayAuth: next.rememberGatewayAuth,
sessionKey: next.sessionKey,
lastActiveSessionKey: next.lastActiveSessionKey,
theme: next.theme,

View File

@ -97,6 +97,17 @@ export function renderLoginGate(state: AppViewState) {
</button>
</div>
</label>
<label class="field" style="gap:8px; flex-direction:row; align-items:center;">
<input
type="checkbox"
.checked=${state.settings.rememberGatewayAuth}
@change=${(e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
state.applySettings({ ...state.settings, rememberGatewayAuth: checked });
}}
/>
<span>Remember me on this device</span>
</label>
<button
class="btn primary login-gate__connect"
@click=${() => state.connect()}