mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 18:01:53 +08:00
Compare commits
3 Commits
codex/slac
...
fix/slack-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99180ec420 | ||
|
|
0e7a6a5272 | ||
|
|
9f07d8906c |
@@ -1,2 +1,2 @@
|
||||
760812c17f7e48d7ceafeebbbe348dad13916ccb9ecaf41b3abc9a09b1e690c1 plugin-sdk-api-baseline.json
|
||||
4d9b76016b2f845e101949a3d2ac92437f49783906d1c263d65f3534bb333de5 plugin-sdk-api-baseline.jsonl
|
||||
d6953afecef50712face2a38f54744af6121bab670695f2ad85ae0048b1105a3 plugin-sdk-api-baseline.json
|
||||
67485084391dada9372630d8cefcd0562461b2af906f5312763a7f9d77a4a29d plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -588,7 +588,7 @@ releases.
|
||||
| `plugin-sdk/reply-history` | Reply-history helpers | `createChannelHistoryWindow`; deprecated map-helper compatibility exports such as `buildPendingHistoryContextFromMap`, `recordPendingHistoryEntry`, and `clearHistoryEntriesIfEnabled` |
|
||||
| `plugin-sdk/reply-reference` | Reply reference planning | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Reply chunk helpers | Text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session store helpers | Store path + updated-at helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session store helpers | Store path, updated-at, and reset-freshness helpers |
|
||||
| `plugin-sdk/state-paths` | State path helpers | State and OAuth dir helpers |
|
||||
| `plugin-sdk/routing` | Routing/session-key helpers | `resolveAgentRoute`, `buildAgentSessionKey`, `resolveDefaultAgentBoundAccountId`, session-key normalization helpers |
|
||||
| `plugin-sdk/status-helpers` | Channel status helpers | Channel/account status summary builders, runtime-state defaults, issue metadata helpers |
|
||||
|
||||
@@ -247,7 +247,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
|
||||
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), bounded recent user/assistant transcript text reads by session identity, legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), reset freshness resolution for one session entry, bounded recent user/assistant transcript text reads by session identity, legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
|
||||
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
|
||||
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
|
||||
@@ -307,7 +307,7 @@ usage endpoint failed or returned no usable usage data.
|
||||
| `plugin-sdk/inline-image-data-url-runtime` | Inline image data URL sanitizer and signature sniffing helpers without the broad media runtime surface |
|
||||
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
|
||||
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
|
||||
| `plugin-sdk/session-store-runtime` | Session-store helpers without broad config writes/maintenance imports |
|
||||
| `plugin-sdk/session-store-runtime` | Session-store and reset-freshness helpers without broad config writes/maintenance imports |
|
||||
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers without database lifecycle controls |
|
||||
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
|
||||
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
|
||||
|
||||
@@ -6,7 +6,6 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { z } from "zod";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { validateSlackBlocksArray } from "./blocks-input.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient, getSlackWriteClient } from "./client.js";
|
||||
import { buildSlackEditTextPayload } from "./edit-text.js";
|
||||
import { resolveSlackMedia } from "./monitor/media.js";
|
||||
@@ -72,22 +71,6 @@ function resolveToken(explicit?: string, accountId?: string, cfg?: OpenClawConfi
|
||||
return token;
|
||||
}
|
||||
|
||||
function resolveSlackActionClientOptions(opts: SlackActionClientOpts) {
|
||||
if (!opts.cfg) {
|
||||
return createSlackApiUrlClientOptions();
|
||||
}
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Slack actions");
|
||||
resolveSlackAccount({ cfg, accountId: opts.accountId });
|
||||
return createSlackApiUrlClientOptions();
|
||||
}
|
||||
|
||||
function slackActionClientOptionArgs(
|
||||
opts: SlackActionClientOpts,
|
||||
): [] | [ReturnType<typeof createSlackApiUrlClientOptions>] {
|
||||
const clientOptions = resolveSlackActionClientOptions(opts);
|
||||
return clientOptions.slackApiUrl ? [clientOptions] : [];
|
||||
}
|
||||
|
||||
function normalizeEmoji(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
@@ -148,10 +131,7 @@ async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write
|
||||
return opts.client;
|
||||
}
|
||||
const token = resolveToken(opts.token, opts.accountId, opts.cfg);
|
||||
const clientOptionArgs = slackActionClientOptionArgs(opts);
|
||||
return mode === "write"
|
||||
? getSlackWriteClient(token, ...clientOptionArgs)
|
||||
: createSlackWebClient(token, ...clientOptionArgs);
|
||||
return mode === "write" ? getSlackWriteClient(token) : createSlackWebClient(token);
|
||||
}
|
||||
|
||||
async function resolveBotUserId(client: WebClient) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { normalizeAllowListLower } from "./monitor/allow-list.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
@@ -77,7 +76,7 @@ export async function resolveSlackConversationInfo(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createSlackWebClient(token, createSlackApiUrlClientOptions());
|
||||
const client = createSlackWebClient(token);
|
||||
if (isNativeImChannel) {
|
||||
const opened = await client.conversations.open({
|
||||
channel: channelId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Slack plugin module implements channel behavior.
|
||||
import {
|
||||
buildLegacyDmAccountAllowlistAdapter,
|
||||
createAccountScopedAllowlistNameResolver,
|
||||
createFlatAllowlistOverrideResolver,
|
||||
} from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
@@ -51,7 +52,6 @@ import {
|
||||
type OpenClawConfig,
|
||||
} from "./channel-api.js";
|
||||
import { resolveSlackChannelType, resolveSlackConversationInfo } from "./channel-type.js";
|
||||
import { createSlackApiUrlClientOptions, type SlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { shouldSuppressLocalSlackExecApprovalPrompt } from "./exec-approvals.js";
|
||||
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
|
||||
import {
|
||||
@@ -405,40 +405,19 @@ function formatSlackScopeDiagnostic(params: {
|
||||
} as const;
|
||||
}
|
||||
|
||||
function slackApiUrlOptionArgs(): [] | [SlackApiUrlClientOptions] {
|
||||
const options = createSlackApiUrlClientOptions();
|
||||
return options.slackApiUrl ? [options] : [];
|
||||
}
|
||||
|
||||
const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver({
|
||||
resolveRecord: (account: ResolvedSlackAccount) => account.channels,
|
||||
label: (key) => key,
|
||||
resolveEntries: (value) => value?.users,
|
||||
});
|
||||
|
||||
const resolveSlackAllowlistNames = async ({
|
||||
accountId,
|
||||
cfg,
|
||||
entries,
|
||||
}: {
|
||||
accountId?: string | null;
|
||||
cfg: OpenClawConfig;
|
||||
entries: string[];
|
||||
}) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const token =
|
||||
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken);
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
return await (
|
||||
await loadSlackResolveUsersModule()
|
||||
).resolveSlackUserAllowlist({
|
||||
token,
|
||||
entries,
|
||||
...createSlackApiUrlClientOptions(),
|
||||
});
|
||||
};
|
||||
const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
|
||||
resolveAccount: resolveSlackAccount,
|
||||
resolveToken: (account: ResolvedSlackAccount) =>
|
||||
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
|
||||
resolveNames: async ({ token, entries }) =>
|
||||
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
|
||||
});
|
||||
|
||||
const slackChannelOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
@@ -675,7 +654,6 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
(await loadSlackResolveChannelsModule()).resolveSlackChannelAllowlist({
|
||||
token,
|
||||
entries: inputsValue,
|
||||
...createSlackApiUrlClientOptions(),
|
||||
}),
|
||||
mapResolved: (entry) =>
|
||||
toResolvedTarget(entry, entry.archived ? "archived" : undefined),
|
||||
@@ -683,14 +661,14 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
}
|
||||
return resolveTargetsWithOptionalToken({
|
||||
token:
|
||||
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
|
||||
normalizeOptionalString(account.userToken) ??
|
||||
normalizeOptionalString(account.botToken),
|
||||
inputs,
|
||||
missingTokenNote: "missing Slack token",
|
||||
resolveWithToken: async ({ token, inputs: inputsLocal }) =>
|
||||
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({
|
||||
token,
|
||||
entries: inputsLocal,
|
||||
...createSlackApiUrlClientOptions(),
|
||||
}),
|
||||
mapResolved: (entry) => toResolvedTarget(entry, entry.note),
|
||||
});
|
||||
@@ -717,9 +695,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
if (!token) {
|
||||
return { ok: false, error: "missing token" };
|
||||
}
|
||||
return await (
|
||||
await loadSlackProbeModule()
|
||||
).probeSlack(token, timeoutMs, ...slackApiUrlOptionArgs());
|
||||
return await (await loadSlackProbeModule()).probeSlack(token, timeoutMs);
|
||||
},
|
||||
formatCapabilitiesProbe: ({ probe }) => {
|
||||
const slackProbe = probe as SlackProbe | undefined;
|
||||
@@ -739,14 +715,13 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
const botToken = account.botToken?.trim();
|
||||
const userToken = account.userToken?.trim();
|
||||
const { fetchSlackScopes } = await loadSlackScopesModule();
|
||||
const apiUrlOptionArgs = slackApiUrlOptionArgs();
|
||||
const botScopes: SlackScopesResultShape = botToken
|
||||
? await fetchSlackScopes(botToken, timeoutMs, ...apiUrlOptionArgs)
|
||||
? await fetchSlackScopes(botToken, timeoutMs)
|
||||
: { ok: false, error: "Slack bot token missing." };
|
||||
lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes }));
|
||||
details.botScopes = botScopes;
|
||||
if (userToken) {
|
||||
const userScopes = await fetchSlackScopes(userToken, timeoutMs, ...apiUrlOptionArgs);
|
||||
const userScopes = await fetchSlackScopes(userToken, timeoutMs);
|
||||
lines.push(formatSlackScopeDiagnostic({ tokenType: "user", result: userScopes }));
|
||||
details.userScopes = userScopes;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { Agent } from "node:http";
|
||||
import type { RetryOptions, WebClientOptions } from "@slack/web-api";
|
||||
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
|
||||
export type SlackApiUrlClientOptions = Pick<WebClientOptions, "slackApiUrl">;
|
||||
|
||||
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
||||
retries: 2,
|
||||
factor: 2,
|
||||
@@ -32,11 +30,12 @@ export const SLACK_WRITE_RETRY_OPTIONS: RetryOptions = {
|
||||
* Returns `undefined` when no proxy env var is configured or when Slack hosts
|
||||
* are excluded by `NO_PROXY`.
|
||||
*/
|
||||
function resolveSlackProxyAgent(targetUrl: string): Agent | undefined {
|
||||
function resolveSlackProxyAgent(): Agent | undefined {
|
||||
try {
|
||||
return createNodeProxyAgent({
|
||||
mode: "env",
|
||||
targetUrl,
|
||||
targetUrl: "https://slack.com/",
|
||||
protocol: "https",
|
||||
});
|
||||
} catch {
|
||||
// Malformed proxy URL; degrade gracefully to direct connection.
|
||||
@@ -44,38 +43,19 @@ function resolveSlackProxyAgent(targetUrl: string): Agent | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSlackApiUrlFromOptions(
|
||||
options: Pick<WebClientOptions, "slackApiUrl">,
|
||||
): string | undefined {
|
||||
const explicit = options.slackApiUrl?.trim();
|
||||
const envDefault = process.env.SLACK_API_URL?.trim();
|
||||
return explicit || envDefault || undefined;
|
||||
}
|
||||
|
||||
export function createSlackApiUrlClientOptions(): SlackApiUrlClientOptions {
|
||||
const slackApiUrl = process.env.SLACK_API_URL?.trim();
|
||||
return slackApiUrl ? { slackApiUrl } : {};
|
||||
}
|
||||
|
||||
export function resolveSlackWebClientOptions(options: WebClientOptions = {}): WebClientOptions {
|
||||
const slackApiUrl = resolveSlackApiUrlFromOptions(options);
|
||||
const proxyTargetUrl = slackApiUrl ?? "https://slack.com/";
|
||||
return {
|
||||
...options,
|
||||
agent: options.agent ?? resolveSlackProxyAgent(proxyTargetUrl),
|
||||
agent: options.agent ?? resolveSlackProxyAgent(),
|
||||
retryConfig: options.retryConfig ?? SLACK_DEFAULT_RETRY_OPTIONS,
|
||||
...(slackApiUrl ? { slackApiUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSlackWriteClientOptions(options: WebClientOptions = {}): WebClientOptions {
|
||||
const slackApiUrl = resolveSlackApiUrlFromOptions(options);
|
||||
const proxyTargetUrl = slackApiUrl ?? "https://slack.com/";
|
||||
return {
|
||||
...options,
|
||||
agent: options.agent ?? resolveSlackProxyAgent(proxyTargetUrl),
|
||||
agent: options.agent ?? resolveSlackProxyAgent(),
|
||||
retryConfig: options.retryConfig ?? SLACK_WRITE_RETRY_OPTIONS,
|
||||
maxRequestConcurrency: options.maxRequestConcurrency ?? 1,
|
||||
...(slackApiUrl ? { slackApiUrl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ let SLACK_DEFAULT_RETRY_OPTIONS: typeof import("./client.js").SLACK_DEFAULT_RETR
|
||||
let SLACK_WRITE_RETRY_OPTIONS: typeof import("./client.js").SLACK_WRITE_RETRY_OPTIONS;
|
||||
let WebClient: ReturnType<typeof vi.fn>;
|
||||
|
||||
const SLACK_API_URL_KEYS = ["SLACK_API_URL", "OPENCLAW_SLACK_API_URL"] as const;
|
||||
const PROXY_KEYS = [
|
||||
"HTTPS_PROXY",
|
||||
"HTTP_PROXY",
|
||||
@@ -57,22 +56,6 @@ function restoreProxyEnvForTest() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearSlackApiUrlEnvForTest() {
|
||||
for (const key of SLACK_API_URL_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
function restoreSlackApiUrlEnvForTest() {
|
||||
for (const key of SLACK_API_URL_KEYS) {
|
||||
if (originalEnv[key] !== undefined) {
|
||||
process.env[key] = originalEnv[key];
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requireAgent<T extends { agent?: unknown }>(options: T): NonNullable<T["agent"]> {
|
||||
if (!options.agent) {
|
||||
throw new Error("expected proxy agent");
|
||||
@@ -107,11 +90,6 @@ beforeAll(async () => {
|
||||
beforeEach(() => {
|
||||
WebClient.mockClear();
|
||||
clearSlackWriteClientCacheForTest();
|
||||
clearSlackApiUrlEnvForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreSlackApiUrlEnvForTest();
|
||||
});
|
||||
|
||||
describe("slack web client config", () => {
|
||||
@@ -128,40 +106,6 @@ describe("slack web client config", () => {
|
||||
expect(options.retryConfig).toBe(customRetry);
|
||||
});
|
||||
|
||||
it("uses explicit Slack API URL as the Slack Web API root", () => {
|
||||
expect(
|
||||
resolveSlackWebClientOptions({ slackApiUrl: "http://127.0.0.1:49152/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49152/api/");
|
||||
expect(
|
||||
resolveSlackWriteClientOptions({ slackApiUrl: "http://127.0.0.1:49152/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49152/api/");
|
||||
});
|
||||
|
||||
it("uses SLACK_API_URL as the default Slack Web API root", () => {
|
||||
process.env.SLACK_API_URL = " http://127.0.0.1:49152/api/ ";
|
||||
|
||||
expect(resolveSlackWebClientOptions().slackApiUrl).toBe("http://127.0.0.1:49152/api/");
|
||||
expect(resolveSlackWriteClientOptions().slackApiUrl).toBe("http://127.0.0.1:49152/api/");
|
||||
});
|
||||
|
||||
it("does not read OPENCLAW_SLACK_API_URL as a default Slack Web API root", () => {
|
||||
process.env.OPENCLAW_SLACK_API_URL = "http://127.0.0.1:49152/api/";
|
||||
|
||||
expect(resolveSlackWebClientOptions().slackApiUrl).toBeUndefined();
|
||||
expect(resolveSlackWriteClientOptions().slackApiUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers explicit Slack API URL over SLACK_API_URL", () => {
|
||||
process.env.SLACK_API_URL = "http://127.0.0.1:49152/api/";
|
||||
|
||||
expect(
|
||||
resolveSlackWebClientOptions({ slackApiUrl: "http://127.0.0.1:49153/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49153/api/");
|
||||
expect(
|
||||
resolveSlackWriteClientOptions({ slackApiUrl: "http://127.0.0.1:49153/api/" }).slackApiUrl,
|
||||
).toBe("http://127.0.0.1:49153/api/");
|
||||
});
|
||||
|
||||
it("passes merged options into WebClient", () => {
|
||||
const customAgent = {} as never;
|
||||
|
||||
@@ -246,38 +190,6 @@ describe("slack web client config", () => {
|
||||
expect(WebClient).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps write clients separated by Slack API URL", () => {
|
||||
clearProxyEnvForTest();
|
||||
try {
|
||||
const first = getSlackWriteClient("xoxb-test", {
|
||||
slackApiUrl: "http://127.0.0.1:49152/api/",
|
||||
});
|
||||
const second = getSlackWriteClient("xoxb-test", {
|
||||
slackApiUrl: "http://127.0.0.1:49153/api/",
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(WebClient).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
restoreProxyEnvForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps write clients separated by SLACK_API_URL", () => {
|
||||
clearProxyEnvForTest();
|
||||
try {
|
||||
process.env.SLACK_API_URL = "http://127.0.0.1:49152/api/";
|
||||
const first = getSlackWriteClient("xoxb-test");
|
||||
process.env.SLACK_API_URL = "http://127.0.0.1:49153/api/";
|
||||
const second = getSlackWriteClient("xoxb-test");
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(WebClient).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
restoreProxyEnvForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds stable non-secret token cache keys", () => {
|
||||
const token = "xoxb-sensitive-token";
|
||||
const first = createSlackTokenCacheKey(token);
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
// Slack plugin module implements client behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { type WebClientOptions, WebClient } from "@slack/web-api";
|
||||
import {
|
||||
resolveSlackWebClientOptions,
|
||||
resolveSlackWriteClientOptions,
|
||||
type SlackApiUrlClientOptions,
|
||||
} from "./client-options.js";
|
||||
import { resolveSlackWebClientOptions, resolveSlackWriteClientOptions } from "./client-options.js";
|
||||
|
||||
const SLACK_WRITE_CLIENT_CACHE_MAX = 32;
|
||||
const slackWriteClientCache = new Map<string, WebClient>();
|
||||
|
||||
export type SlackWriteClientCacheOptions = SlackApiUrlClientOptions;
|
||||
|
||||
export {
|
||||
createSlackApiUrlClientOptions,
|
||||
resolveSlackWebClientOptions,
|
||||
resolveSlackWriteClientOptions,
|
||||
type SlackApiUrlClientOptions,
|
||||
SLACK_DEFAULT_RETRY_OPTIONS,
|
||||
SLACK_WRITE_RETRY_OPTIONS,
|
||||
} from "./client-options.js";
|
||||
@@ -33,27 +25,15 @@ export function createSlackTokenCacheKey(token: string): string {
|
||||
return `sha256:${createHash("sha256").update(token).digest("base64url")}`;
|
||||
}
|
||||
|
||||
function createSlackWriteClientCacheKey(
|
||||
token: string,
|
||||
options: SlackWriteClientCacheOptions,
|
||||
): string {
|
||||
export function getSlackWriteClient(token: string): WebClient {
|
||||
const tokenKey = createSlackTokenCacheKey(token);
|
||||
return options.slackApiUrl ? `${tokenKey}:api:${options.slackApiUrl}` : tokenKey;
|
||||
}
|
||||
|
||||
export function getSlackWriteClient(
|
||||
token: string,
|
||||
options: SlackWriteClientCacheOptions = {},
|
||||
): WebClient {
|
||||
const resolvedOptions = resolveSlackWriteClientOptions(options);
|
||||
const tokenKey = createSlackWriteClientCacheKey(token, resolvedOptions);
|
||||
const cached = slackWriteClientCache.get(tokenKey);
|
||||
if (cached) {
|
||||
slackWriteClientCache.delete(tokenKey);
|
||||
slackWriteClientCache.set(tokenKey, cached);
|
||||
return cached;
|
||||
}
|
||||
const client = new WebClient(token, resolvedOptions);
|
||||
const client = createSlackWriteClient(token);
|
||||
if (slackWriteClientCache.size >= SLACK_WRITE_CLIENT_CACHE_MAX) {
|
||||
const oldestTokenKey = slackWriteClientCache.keys().next().value;
|
||||
if (oldestTokenKey) {
|
||||
|
||||
@@ -38,22 +38,6 @@ describe("slack config schema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects Slack Web API URL config overrides", () => {
|
||||
const res = SlackConfigSchema.safeParse({
|
||||
apiUrl: "http://127.0.0.1:49152/api/",
|
||||
accounts: { ops: { apiUrl: "http://127.0.0.1:49153/api/" } },
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
if (!res.success) {
|
||||
expect(
|
||||
res.error.issues.some(
|
||||
(issue) => issue.code === "unrecognized_keys" && issue.keys.includes("apiUrl"),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts unfurl controls at root and account level", () => {
|
||||
const res = SlackConfigSchema.safeParse({
|
||||
unfurlLinks: false,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
|
||||
type SlackUser = {
|
||||
@@ -51,10 +50,9 @@ type SlackAuthTestResponse = {
|
||||
team?: string;
|
||||
};
|
||||
|
||||
function createSlackDirectoryClient(params: DirectoryConfigParams) {
|
||||
function resolveReadToken(params: DirectoryConfigParams): string | undefined {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const token = account.userToken ?? account.botToken?.trim();
|
||||
return token ? createSlackWebClient(token, createSlackApiUrlClientOptions()) : null;
|
||||
return account.userToken ?? account.botToken?.trim();
|
||||
}
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
@@ -103,10 +101,11 @@ function slackUserToDirectoryEntry(
|
||||
export async function getSlackDirectorySelfLive(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry | null> {
|
||||
const client = createSlackDirectoryClient(params);
|
||||
if (!client) {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const client = createSlackWebClient(token);
|
||||
const auth = (await client.auth.test()) as SlackAuthTestResponse;
|
||||
const userId = normalizeOptionalString(auth.user_id);
|
||||
if (!userId) {
|
||||
@@ -126,10 +125,11 @@ export async function getSlackDirectorySelfLive(
|
||||
export async function listSlackDirectoryPeersLive(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const client = createSlackDirectoryClient(params);
|
||||
if (!client) {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
const client = createSlackWebClient(token);
|
||||
const query = normalizeQuery(params.query);
|
||||
const members: SlackUser[] = [];
|
||||
let cursor: string | undefined;
|
||||
@@ -172,10 +172,11 @@ export async function listSlackDirectoryPeersLive(
|
||||
export async function listSlackDirectoryGroupsLive(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const client = createSlackDirectoryClient(params);
|
||||
if (!client) {
|
||||
const token = resolveReadToken(params);
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
const client = createSlackWebClient(token);
|
||||
const query = normalizeQuery(params.query);
|
||||
const channels: SlackChannel[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
@@ -3,6 +3,8 @@ export { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
export {
|
||||
readSessionUpdatedAt,
|
||||
resolveChannelResetConfig,
|
||||
resolveSessionEntryFreshness,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
updateLastRoute,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||
import type { SlackMessageEvent } from "../../types.js";
|
||||
import { resolveSlackAllowListMatch } from "../allow-list.js";
|
||||
import { readSessionUpdatedAt } from "../config.runtime.js";
|
||||
import { resolveChannelResetConfig, resolveSessionEntryFreshness } from "../config.runtime.js";
|
||||
import type { SlackMonitorContext } from "../context.js";
|
||||
import type { SlackMediaResult } from "../media-types.js";
|
||||
import { resolveSlackThreadHistory, type SlackThreadStarter } from "../thread.js";
|
||||
@@ -35,7 +35,7 @@ function loadSlackMediaModule(): Promise<SlackMediaModule> {
|
||||
type SlackThreadContextData = {
|
||||
threadStarterBody: string | undefined;
|
||||
threadHistoryBody: string | undefined;
|
||||
threadSessionPreviousTimestamp: number | undefined;
|
||||
shouldSeedInitialThreadContext: boolean;
|
||||
threadLabel: string | undefined;
|
||||
threadStarterMedia: SlackMediaResult[] | null;
|
||||
};
|
||||
@@ -125,19 +125,32 @@ export async function resolveSlackThreadContextData(params: {
|
||||
let threadHistoryBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
let threadStarterMedia: SlackMediaResult[] | null = null;
|
||||
const threadSessionPreviousTimestamp =
|
||||
const threadSessionFreshness =
|
||||
params.isThreadReply && params.threadTs
|
||||
? readSessionUpdatedAt({
|
||||
? resolveSessionEntryFreshness({
|
||||
storePath: params.storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionCfg: params.ctx.cfg.session,
|
||||
resetType: "thread",
|
||||
resetOverride: resolveChannelResetConfig({
|
||||
sessionCfg: params.ctx.cfg.session,
|
||||
channel: "slack",
|
||||
}),
|
||||
})
|
||||
: undefined;
|
||||
const shouldSeedInitialThreadContext = Boolean(
|
||||
params.isThreadReply &&
|
||||
params.threadTs &&
|
||||
(!threadSessionFreshness || threadSessionFreshness.state !== "fresh"),
|
||||
);
|
||||
const shouldLoadInitialThreadHistory =
|
||||
shouldSeedInitialThreadContext || params.forceInitialHistory === true;
|
||||
|
||||
if (!params.isThreadReply || !params.threadTs) {
|
||||
return {
|
||||
threadStarterBody,
|
||||
threadHistoryBody,
|
||||
threadSessionPreviousTimestamp,
|
||||
shouldSeedInitialThreadContext,
|
||||
threadLabel,
|
||||
threadStarterMedia,
|
||||
};
|
||||
@@ -195,10 +208,9 @@ export async function resolveSlackThreadContextData(params: {
|
||||
threadLabel = `Slack thread ${params.roomLabel}`;
|
||||
}
|
||||
|
||||
const isNewThreadSession = !threadSessionPreviousTimestamp;
|
||||
const includeBotStarterAsRootContext = shouldIncludeBotThreadStarterContext({
|
||||
starterIsCurrentBot,
|
||||
isNewThreadSession,
|
||||
isNewThreadSession: shouldSeedInitialThreadContext,
|
||||
hasStarterText: Boolean(starter?.text),
|
||||
});
|
||||
|
||||
@@ -218,10 +230,7 @@ export async function resolveSlackThreadContextData(params: {
|
||||
|
||||
const threadInitialHistoryLimit = params.account.config?.thread?.initialHistoryLimit ?? 20;
|
||||
|
||||
if (
|
||||
threadInitialHistoryLimit > 0 &&
|
||||
(!threadSessionPreviousTimestamp || params.forceInitialHistory)
|
||||
) {
|
||||
if (threadInitialHistoryLimit > 0 && shouldLoadInitialThreadHistory) {
|
||||
const currentBotRootTs = starter?.ts ?? params.threadTs;
|
||||
const threadHistory = await resolveSlackThreadHistory({
|
||||
channelId: params.message.channel,
|
||||
@@ -333,7 +342,7 @@ export async function resolveSlackThreadContextData(params: {
|
||||
return {
|
||||
threadStarterBody,
|
||||
threadHistoryBody,
|
||||
threadSessionPreviousTimestamp,
|
||||
shouldSeedInitialThreadContext,
|
||||
threadLabel,
|
||||
threadStarterMedia,
|
||||
};
|
||||
|
||||
@@ -1870,12 +1870,15 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: "200.000",
|
||||
});
|
||||
const now = Date.now();
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
[threadKeys.sessionKey]: {
|
||||
sessionId: "existing-thread-session",
|
||||
updatedAt: Date.now(),
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now,
|
||||
lastInteractionAt: now,
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
@@ -1905,6 +1908,208 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
|
||||
expect(replies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads bounded thread history for existing thread sessions stale under reset policy", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const now = Date.now();
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
teamId: "T1",
|
||||
peer: { kind: "channel", id: "C123" },
|
||||
});
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: "300.000",
|
||||
});
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
[threadKeys.sessionKey]: {
|
||||
sessionId: "stale-thread-session",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
|
||||
const replies = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ text: "starter", user: "U2", ts: "300.000" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{ text: "starter", user: "U2", ts: "300.000" },
|
||||
{ text: "assistant prior output", bot_id: "B1", ts: "300.500" },
|
||||
{ text: "prior human context", user: "U1", ts: "300.800" },
|
||||
{ text: "current post-reset message", user: "U1", ts: "301.000" },
|
||||
],
|
||||
response_metadata: { next_cursor: "" },
|
||||
});
|
||||
const slackCtx = createThreadSlackCtx({ cfg, replies });
|
||||
slackCtx.threadInheritParent = true;
|
||||
slackCtx.resolveUserName = async (id: string) => ({
|
||||
name: id === "U1" ? "Alice" : "Bob",
|
||||
});
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
|
||||
const prepared = await prepareMessageWith(
|
||||
slackCtx,
|
||||
createSlackAccount({
|
||||
replyToMode: "all",
|
||||
thread: { initialHistoryLimit: 10, inheritParent: true },
|
||||
}),
|
||||
createThreadReplyMessage({
|
||||
text: "current post-reset message",
|
||||
ts: "301.000",
|
||||
thread_ts: "300.000",
|
||||
}),
|
||||
);
|
||||
|
||||
assertPrepared(prepared);
|
||||
expect(prepared.ctxPayload.SessionKey).toBe(threadKeys.sessionKey);
|
||||
expect(prepared.ctxPayload.IsFirstThreadTurn).toBe(true);
|
||||
expect(prepared.ctxPayload.ThreadStarterBody).toBe("starter");
|
||||
expect(prepared.ctxPayload.ThreadHistoryBody).toContain("prior human context");
|
||||
expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("assistant prior output");
|
||||
expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("current post-reset message");
|
||||
expect(prepared.ctxPayload.ParentSessionKey).toBe(route.sessionKey);
|
||||
expect(replies).toHaveBeenCalledTimes(2);
|
||||
expect(replies).toHaveBeenLastCalledWith({
|
||||
channel: "C123",
|
||||
ts: "300.000",
|
||||
limit: 200,
|
||||
inclusive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provider-owned thread sessions existing when reset policy is implicit", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const now = Date.now();
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
teamId: "T1",
|
||||
peer: { kind: "channel", id: "C123" },
|
||||
});
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: "350.000",
|
||||
});
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
[threadKeys.sessionKey]: {
|
||||
sessionId: "provider-owned-thread-session",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
providerOverride: "claude-cli",
|
||||
cliSessionBindings: {
|
||||
"claude-cli": { sessionId: "claude-cli-thread-session" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
|
||||
const replies = vi.fn().mockResolvedValueOnce({
|
||||
messages: [{ text: "starter", user: "U2", ts: "350.000" }],
|
||||
});
|
||||
const slackCtx = createThreadSlackCtx({ cfg, replies });
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" });
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
|
||||
const prepared = await prepareMessageWith(
|
||||
slackCtx,
|
||||
createSlackAccount({
|
||||
replyToMode: "all",
|
||||
thread: { initialHistoryLimit: 10 },
|
||||
}),
|
||||
createThreadReplyMessage({
|
||||
text: "reply after implicit reset boundary",
|
||||
ts: "351.000",
|
||||
thread_ts: "350.000",
|
||||
}),
|
||||
);
|
||||
|
||||
assertPrepared(prepared);
|
||||
expect(prepared.ctxPayload.IsFirstThreadTurn).toBeUndefined();
|
||||
expect(prepared.ctxPayload.ThreadStarterBody).toBeUndefined();
|
||||
expect(prepared.ctxPayload.ThreadHistoryBody).toBeUndefined();
|
||||
expect(replies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps initialHistoryLimit zero as a hard disable for stale thread sessions", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const now = Date.now();
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
|
||||
} as OpenClawConfig;
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "slack",
|
||||
accountId: "default",
|
||||
teamId: "T1",
|
||||
peer: { kind: "channel", id: "C123" },
|
||||
});
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: "400.000",
|
||||
});
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
[threadKeys.sessionKey]: {
|
||||
sessionId: "stale-zero-history-thread-session",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
|
||||
const replies = vi.fn().mockResolvedValueOnce({
|
||||
messages: [{ text: "starter", user: "U2", ts: "400.000" }],
|
||||
});
|
||||
const slackCtx = createThreadSlackCtx({ cfg, replies });
|
||||
slackCtx.resolveUserName = async () => ({ name: "Alice" });
|
||||
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
|
||||
|
||||
const prepared = await prepareMessageWith(
|
||||
slackCtx,
|
||||
createSlackAccount({
|
||||
replyToMode: "all",
|
||||
thread: { initialHistoryLimit: 0 },
|
||||
}),
|
||||
createThreadReplyMessage({
|
||||
text: "current post-reset message",
|
||||
ts: "401.000",
|
||||
thread_ts: "400.000",
|
||||
}),
|
||||
);
|
||||
|
||||
assertPrepared(prepared);
|
||||
expect(prepared.ctxPayload.IsFirstThreadTurn).toBe(true);
|
||||
expect(prepared.ctxPayload.ThreadStarterBody).toBe("starter");
|
||||
expect(prepared.ctxPayload.ThreadHistoryBody).toBeUndefined();
|
||||
expect(replies).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops ambiguous thread replies instead of treating them as root messages", async () => {
|
||||
const { storePath } = storeFixture.makeTmpStorePath();
|
||||
const cfg = {
|
||||
|
||||
@@ -1225,7 +1225,7 @@ export async function prepareSlackMessage(params: {
|
||||
const {
|
||||
threadStarterBody,
|
||||
threadHistoryBody,
|
||||
threadSessionPreviousTimestamp,
|
||||
shouldSeedInitialThreadContext,
|
||||
threadLabel,
|
||||
threadStarterMedia,
|
||||
} = await resolveSlackThreadContextData({
|
||||
@@ -1320,7 +1320,7 @@ export async function prepareSlackMessage(params: {
|
||||
thread: {
|
||||
// Only include thread starter body for NEW sessions (existing sessions already have it in their transcript)
|
||||
starterBody:
|
||||
!directThreadRoutedToDmSession && !threadSessionPreviousTimestamp
|
||||
!directThreadRoutedToDmSession && shouldSeedInitialThreadContext
|
||||
? threadStarterBody
|
||||
: undefined,
|
||||
historyBody: supplementalThreadHistoryBody,
|
||||
@@ -1340,7 +1340,7 @@ export async function prepareSlackMessage(params: {
|
||||
isThreadReply &&
|
||||
threadTs &&
|
||||
!directThreadRoutedToDmSession &&
|
||||
!threadSessionPreviousTimestamp
|
||||
shouldSeedInitialThreadContext
|
||||
? true
|
||||
: undefined,
|
||||
...buildSlackMentionContextPayload({
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
resolveSlackAccountDmPolicy,
|
||||
} from "../accounts.js";
|
||||
import { isSlackAnyNativeApprovalClientEnabled } from "../approval-native-gates.js";
|
||||
import { createSlackApiUrlClientOptions, resolveSlackWebClientOptions } from "../client-options.js";
|
||||
import { resolveSlackWebClientOptions } from "../client-options.js";
|
||||
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
|
||||
import { SLACK_TEXT_LIMIT } from "../limits.js";
|
||||
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
|
||||
@@ -288,8 +288,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const typingReaction = slackCfg.typingReaction?.trim() ?? "";
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||
const slackApiUrlClientOptions = createSlackApiUrlClientOptions();
|
||||
const clientOptions = resolveSlackWebClientOptions(slackApiUrlClientOptions);
|
||||
const clientOptions = resolveSlackWebClientOptions();
|
||||
const { app, receiver, socketModeLogger } = createSlackBoltApp({
|
||||
interop: await getSlackBoltInterop(),
|
||||
slackMode,
|
||||
@@ -463,7 +462,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const resolved = await resolveSlackChannelAllowlist({
|
||||
token: resolveToken,
|
||||
entries,
|
||||
...slackApiUrlClientOptions,
|
||||
});
|
||||
const nextChannels = { ...channelsConfig };
|
||||
const mapping: string[] = [];
|
||||
@@ -509,7 +507,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const resolvedUsers = await resolveSlackUserAllowlist({
|
||||
token: resolveToken,
|
||||
entries: allowEntries,
|
||||
...slackApiUrlClientOptions,
|
||||
});
|
||||
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(
|
||||
resolvedUsers,
|
||||
@@ -556,7 +553,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const resolvedUsers = await resolveSlackUserAllowlist({
|
||||
token: resolveToken,
|
||||
entries: Array.from(userEntries),
|
||||
...slackApiUrlClientOptions,
|
||||
});
|
||||
const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(
|
||||
resolvedUsers,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Slack plugin module implements probe behavior.
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { createSlackWebClient, type SlackApiUrlClientOptions } from "./client.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { formatSlackError } from "./errors.js";
|
||||
|
||||
export type SlackProbe = BaseProbeResult & {
|
||||
@@ -11,14 +11,8 @@ export type SlackProbe = BaseProbeResult & {
|
||||
team?: { id?: string; name?: string };
|
||||
};
|
||||
|
||||
export async function probeSlack(
|
||||
token: string,
|
||||
timeoutMs = 2500,
|
||||
options: SlackApiUrlClientOptions = {},
|
||||
): Promise<SlackProbe> {
|
||||
const client = options.slackApiUrl
|
||||
? createSlackWebClient(token, options)
|
||||
: createSlackWebClient(token);
|
||||
export async function probeSlack(token: string, timeoutMs = 2500): Promise<SlackProbe> {
|
||||
const client = createSlackWebClient(token);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await withTimeout(client.auth.test(), timeoutMs);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Slack plugin module implements resolve channels behavior.
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { SlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import {
|
||||
collectSlackCursorItems,
|
||||
@@ -102,14 +101,8 @@ export async function resolveSlackChannelAllowlist(params: {
|
||||
token: string;
|
||||
entries: string[];
|
||||
client?: WebClient;
|
||||
slackApiUrl?: SlackApiUrlClientOptions["slackApiUrl"];
|
||||
}): Promise<SlackChannelResolution[]> {
|
||||
const client =
|
||||
params.client ??
|
||||
createSlackWebClient(
|
||||
params.token,
|
||||
params.slackApiUrl ? { slackApiUrl: params.slackApiUrl } : {},
|
||||
);
|
||||
const client = params.client ?? createSlackWebClient(params.token);
|
||||
const channels = await listSlackChannels(client);
|
||||
return resolveSlackAllowlistEntries<
|
||||
{ id?: string; name?: string },
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { SlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import {
|
||||
collectSlackCursorItems,
|
||||
@@ -154,14 +153,8 @@ export async function resolveSlackUserAllowlist(params: {
|
||||
token: string;
|
||||
entries: string[];
|
||||
client?: WebClient;
|
||||
slackApiUrl?: SlackApiUrlClientOptions["slackApiUrl"];
|
||||
}): Promise<SlackUserResolution[]> {
|
||||
const client =
|
||||
params.client ??
|
||||
createSlackWebClient(
|
||||
params.token,
|
||||
params.slackApiUrl ? { slackApiUrl: params.slackApiUrl } : {},
|
||||
);
|
||||
const client = params.client ?? createSlackWebClient(params.token);
|
||||
const users = await listSlackUsers(client);
|
||||
return resolveSlackAllowlistEntries<
|
||||
{ id?: string; name?: string; email?: string },
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
sortUniqueStrings,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { createSlackWebClient, type SlackApiUrlClientOptions } from "./client.js";
|
||||
import { createSlackWebClient } from "./client.js";
|
||||
import { formatSlackError } from "./errors.js";
|
||||
|
||||
export type SlackScopesResult = {
|
||||
@@ -95,9 +95,8 @@ async function callSlack(
|
||||
export async function fetchSlackScopes(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
options: SlackApiUrlClientOptions = {},
|
||||
): Promise<SlackScopesResult> {
|
||||
const client = createSlackWebClient(token, { ...options, timeout: timeoutMs });
|
||||
const client = createSlackWebClient(token, { timeout: timeoutMs });
|
||||
const attempts: SlackScopesMethod[] = ["auth.test", "auth.scopes", "apps.permissions.info"];
|
||||
const errors: string[] = [];
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import type { SlackTokenSource } from "./accounts.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
|
||||
import { validateSlackBlocksArray } from "./blocks-input.js";
|
||||
import { createSlackApiUrlClientOptions } from "./client-options.js";
|
||||
import { createSlackTokenCacheKey, getSlackWriteClient } from "./client.js";
|
||||
import { markdownToSlackMrkdwnChunks } from "./format.js";
|
||||
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||
@@ -751,7 +750,7 @@ async function sendMessageSlackQueuedInner(params: {
|
||||
blocks?: (Block | KnownBlock)[];
|
||||
}): Promise<SlackSendResult> {
|
||||
const { opts, cfg, account, token, recipient, blocks, trimmedMessage } = params;
|
||||
const client = opts.client ?? getSlackWriteClient(token, createSlackApiUrlClientOptions());
|
||||
const client = opts.client ?? getSlackWriteClient(token);
|
||||
const identity = resolveSlackSendIdentity({
|
||||
accountId: account.accountId,
|
||||
explicit: opts.identity,
|
||||
|
||||
@@ -117,6 +117,9 @@ export const pluginSdkDocMetadata = {
|
||||
"runtime-store": {
|
||||
category: "runtime",
|
||||
},
|
||||
"session-store-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
"session-transcript-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
|
||||
@@ -163,7 +163,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
|
||||
"channel-pairing-paths": 1,
|
||||
"channel-policy": 8,
|
||||
"channel-route": 5,
|
||||
"session-store-runtime": 1,
|
||||
"session-store-runtime": 2,
|
||||
"session-transcript-runtime": 1,
|
||||
"group-access": 13,
|
||||
"media-generation-runtime-shared": 3,
|
||||
@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
|
||||
try {
|
||||
budgets = {
|
||||
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10388),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5214),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10392),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5215),
|
||||
publicDeprecatedExports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
||||
3247,
|
||||
|
||||
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import * as jsonFiles from "../infra/json-files.js";
|
||||
import {
|
||||
cleanupSessionLifecycleArtifacts,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
listSessionEntries,
|
||||
patchSessionEntry,
|
||||
readSessionUpdatedAt,
|
||||
resolveSessionEntryFreshness,
|
||||
saveSessionStore,
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
@@ -27,6 +30,7 @@ describe("session-store-runtime compatibility surface", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -70,6 +74,262 @@ describe("session-store-runtime compatibility surface", () => {
|
||||
expect(getSessionEntry({ sessionKey, storePath })?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns missing state with a resolved reset policy for absent entries", () => {
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey: "agent:main:missing:thread:100.000",
|
||||
storePath,
|
||||
sessionCfg: {},
|
||||
resetType: "thread",
|
||||
now: new Date("2026-01-02T12:00:00Z").getTime(),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
state: "missing",
|
||||
entry: undefined,
|
||||
freshness: undefined,
|
||||
resetType: "thread",
|
||||
resetPolicy: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves stale daily freshness from lifecycle timestamps instead of activity", async () => {
|
||||
const sessionKey = "agent:main:main:thread:100.000";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: "session-stale-thread",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * DAY_MS,
|
||||
lastInteractionAt: now - 2 * DAY_MS,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: {},
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("stale");
|
||||
expect(result.entry?.sessionId).toBe("session-stale-thread");
|
||||
expect(result.resetType).toBe("thread");
|
||||
expect(result.freshness).toMatchObject({
|
||||
fresh: false,
|
||||
staleReason: "daily",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provider-owned sessions fresh when reset policy is implicit", async () => {
|
||||
const sessionKey = "agent:main:main:thread:provider-owned";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: "session-provider-owned",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * DAY_MS,
|
||||
lastInteractionAt: now - 2 * DAY_MS,
|
||||
providerOverride: "claude-cli",
|
||||
cliSessionBindings: {
|
||||
"claude-cli": { sessionId: "cli-session-provider-owned" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: {},
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("fresh");
|
||||
expect(result.freshness).toMatchObject({ fresh: true });
|
||||
});
|
||||
|
||||
it("applies configured reset policies to provider-owned sessions", async () => {
|
||||
const sessionKey = "agent:main:main:thread:provider-owned-configured";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: "session-provider-owned-configured",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 2 * DAY_MS,
|
||||
lastInteractionAt: now - 2 * DAY_MS,
|
||||
providerOverride: "claude-cli",
|
||||
cliSessionBindings: {
|
||||
"claude-cli": { sessionId: "cli-session-provider-owned-configured" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: { reset: { mode: "daily" } },
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("stale");
|
||||
expect(result.freshness).toMatchObject({
|
||||
fresh: false,
|
||||
staleReason: "daily",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves fresh daily freshness for active lifecycle timestamps", async () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: "session-fresh",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now - 60_000,
|
||||
lastInteractionAt: now - 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: {},
|
||||
resetType: "direct",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("fresh");
|
||||
expect(result.entry?.sessionId).toBe("session-fresh");
|
||||
expect(result.resetType).toBe("direct");
|
||||
expect(result.freshness).toMatchObject({ fresh: true });
|
||||
});
|
||||
|
||||
it("honors reset overrides when resolving entry freshness", async () => {
|
||||
const sessionKey = "agent:main:main:thread:idle";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionId: "session-idle-stale",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now,
|
||||
lastInteractionAt: now - 60 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: { reset: { mode: "daily" } },
|
||||
resetOverride: { mode: "idle", idleMinutes: 30 },
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("stale");
|
||||
expect(result.resetPolicy).toMatchObject({
|
||||
mode: "idle",
|
||||
idleMinutes: 30,
|
||||
});
|
||||
expect(result.freshness).toMatchObject({
|
||||
fresh: false,
|
||||
staleReason: "idle",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses runtime session config when store path and session config are omitted", async () => {
|
||||
const sessionKey = "agent:main:main:thread:runtime-config";
|
||||
const runtimeStorePath = path.join(tempDir, "runtime-sessions.json");
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
setRuntimeConfigSnapshot({
|
||||
session: {
|
||||
store: runtimeStorePath,
|
||||
reset: { mode: "idle", idleMinutes: 30 },
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath: runtimeStorePath,
|
||||
entry: {
|
||||
sessionId: "session-runtime-config",
|
||||
updatedAt: now,
|
||||
sessionStartedAt: now,
|
||||
lastInteractionAt: now - 60 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("stale");
|
||||
expect(result.entry?.sessionId).toBe("session-runtime-config");
|
||||
expect(result.resetPolicy).toMatchObject({
|
||||
mode: "idle",
|
||||
idleMinutes: 30,
|
||||
});
|
||||
expect(result.freshness).toMatchObject({
|
||||
fresh: false,
|
||||
staleReason: "idle",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses transcript header startedAt when entry lifecycle metadata is missing", async () => {
|
||||
const sessionKey = "agent:main:main:thread:header";
|
||||
const now = new Date("2026-01-02T12:00:00Z").getTime();
|
||||
const headerTimestamp = new Date(now - 2 * DAY_MS).toISOString();
|
||||
const transcriptPath = path.join(tempDir, "session-header-fallback.jsonl");
|
||||
fs.writeFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
id: "session-header-fallback",
|
||||
timestamp: headerTimestamp,
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await upsertSessionEntry({
|
||||
sessionKey,
|
||||
storePath,
|
||||
entry: {
|
||||
sessionFile: transcriptPath,
|
||||
sessionId: "session-header-fallback",
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveSessionEntryFreshness({
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionCfg: {},
|
||||
resetType: "thread",
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.state).toBe("stale");
|
||||
expect(result.lifecycleTimestamps.sessionStartedAt).toBe(Date.parse(headerTimestamp));
|
||||
expect(result.freshness).toMatchObject({
|
||||
fresh: false,
|
||||
staleReason: "daily",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the public entry mutation signature while delegating to the seam", async () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
|
||||
@@ -338,7 +598,9 @@ describe("session-store-runtime compatibility surface", () => {
|
||||
sessionId: "regular",
|
||||
});
|
||||
expect(
|
||||
fs.readdirSync(tempDir).filter((file) => file.startsWith("lifecycle-owned-old.jsonl.deleted.")),
|
||||
fs
|
||||
.readdirSync(tempDir)
|
||||
.filter((file) => file.startsWith("lifecycle-owned-old.jsonl.deleted.")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
// Narrow session-store helpers for channel hot paths.
|
||||
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import { resolveSessionLifecycleTimestamps } from "../config/sessions/lifecycle.js";
|
||||
import { resolveStorePath as resolveSessionStorePath } from "../config/sessions/paths.js";
|
||||
import {
|
||||
evaluateSessionFreshness as evaluateSessionFreshnessImpl,
|
||||
resolveSessionResetPolicy as resolveSessionResetPolicyImpl,
|
||||
type SessionFreshness,
|
||||
type SessionResetPolicy,
|
||||
type SessionResetType,
|
||||
} from "../config/sessions/reset.js";
|
||||
import {
|
||||
cleanupSessionLifecycleArtifacts as cleanupAccessorSessionLifecycleArtifacts,
|
||||
listSessionEntries as listAccessorSessionEntries,
|
||||
@@ -15,6 +26,8 @@ import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/sto
|
||||
import { normalizeResolvedMaintenanceConfigInput } from "../config/sessions/store-maintenance.js";
|
||||
import type { ResolvedSessionMaintenanceConfigInput } from "../config/sessions/store.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { SessionConfig, SessionResetConfig } from "../config/types.base.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
|
||||
type SessionStoreReadParams = {
|
||||
agentId?: string;
|
||||
@@ -51,6 +64,36 @@ type PatchSessionEntryParams = SessionStoreReadParams & {
|
||||
|
||||
type ReadSessionUpdatedAtParams = SessionStoreReadParams;
|
||||
|
||||
export type ResolveSessionEntryFreshnessParams = SessionStoreReadParams & {
|
||||
now?: number;
|
||||
resetOverride?: SessionResetConfig;
|
||||
resetType: SessionResetType;
|
||||
sessionCfg?: SessionConfig;
|
||||
};
|
||||
|
||||
export type SessionEntryLifecycleTimestamps = {
|
||||
sessionStartedAt?: number;
|
||||
lastInteractionAt?: number;
|
||||
};
|
||||
|
||||
export type ResolvedSessionEntryFreshness =
|
||||
| {
|
||||
state: "missing";
|
||||
entry: undefined;
|
||||
freshness: undefined;
|
||||
lifecycleTimestamps: SessionEntryLifecycleTimestamps;
|
||||
resetPolicy: SessionResetPolicy;
|
||||
resetType: SessionResetType;
|
||||
}
|
||||
| {
|
||||
state: "fresh" | "stale";
|
||||
entry: SessionEntry;
|
||||
freshness: SessionFreshness;
|
||||
lifecycleTimestamps: SessionEntryLifecycleTimestamps;
|
||||
resetPolicy: SessionResetPolicy;
|
||||
resetType: SessionResetType;
|
||||
};
|
||||
|
||||
type UpdateSessionStoreEntryParams = {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
@@ -81,6 +124,24 @@ type SessionLifecycleArtifactsCleanupResult = {
|
||||
removedEntries: number;
|
||||
};
|
||||
|
||||
function hasProviderOwnedSession(entry: SessionEntry | undefined): boolean {
|
||||
const provider = normalizeOptionalString(entry?.providerOverride ?? entry?.modelProvider);
|
||||
if (!entry || !provider) {
|
||||
return false;
|
||||
}
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
if (normalizeOptionalString(entry.cliSessionBindings?.[normalizedProvider]?.sessionId)) {
|
||||
return true;
|
||||
}
|
||||
if (normalizeOptionalString(entry.cliSessionIds?.[normalizedProvider])) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
normalizedProvider === "claude-cli" &&
|
||||
Boolean(normalizeOptionalString(entry.claudeCliSessionId))
|
||||
);
|
||||
}
|
||||
|
||||
function toSessionAccessScope(params: SessionStoreReadParams): SessionAccessScope {
|
||||
// Maintainer note: keep this adapter narrow so plugin callers retain the
|
||||
// object-parameter API while internal accessor-only options stay private.
|
||||
@@ -143,6 +204,67 @@ export function readSessionUpdatedAt(params: ReadSessionUpdatedAtParams): number
|
||||
return readAccessorSessionUpdatedAt(toSessionAccessScope(params));
|
||||
}
|
||||
|
||||
/** Resolves one session entry's reset freshness using the runtime lifecycle rules. */
|
||||
export function resolveSessionEntryFreshness(
|
||||
params: ResolveSessionEntryFreshnessParams,
|
||||
): ResolvedSessionEntryFreshness {
|
||||
const agentId = params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey);
|
||||
const sessionCfg = params.sessionCfg ?? getRuntimeConfig().session;
|
||||
const storePath =
|
||||
params.storePath ??
|
||||
resolveSessionStorePath(sessionCfg?.store, {
|
||||
agentId,
|
||||
env: params.env,
|
||||
});
|
||||
const entry = loadSessionEntry(
|
||||
toSessionAccessScope({
|
||||
...params,
|
||||
agentId,
|
||||
storePath,
|
||||
}),
|
||||
);
|
||||
const resetType = params.resetType;
|
||||
const resetPolicy = resolveSessionResetPolicyImpl({
|
||||
sessionCfg,
|
||||
resetType,
|
||||
resetOverride: params.resetOverride,
|
||||
});
|
||||
const lifecycleTimestamps = resolveSessionLifecycleTimestamps({
|
||||
entry,
|
||||
agentId,
|
||||
storePath,
|
||||
});
|
||||
const base = {
|
||||
lifecycleTimestamps,
|
||||
resetPolicy,
|
||||
resetType,
|
||||
};
|
||||
if (!entry) {
|
||||
return {
|
||||
state: "missing",
|
||||
entry: undefined,
|
||||
freshness: undefined,
|
||||
...base,
|
||||
};
|
||||
}
|
||||
const freshness =
|
||||
resetPolicy.configured !== true && hasProviderOwnedSession(entry)
|
||||
? ({ fresh: true } satisfies SessionFreshness)
|
||||
: evaluateSessionFreshnessImpl({
|
||||
updatedAt: entry.updatedAt,
|
||||
sessionStartedAt: lifecycleTimestamps.sessionStartedAt,
|
||||
lastInteractionAt: lifecycleTimestamps.lastInteractionAt,
|
||||
now: params.now ?? Date.now(),
|
||||
policy: resetPolicy,
|
||||
});
|
||||
return {
|
||||
state: freshness.fresh ? "fresh" : "stale",
|
||||
entry,
|
||||
freshness,
|
||||
...base,
|
||||
};
|
||||
}
|
||||
|
||||
/** Updates an existing session entry by store path and session key. */
|
||||
export async function updateSessionStoreEntry(
|
||||
params: UpdateSessionStoreEntryParams,
|
||||
|
||||
Reference in New Issue
Block a user