mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 16:04:33 +00:00
CLI: make read-only SecretRef status flows degrade safely (#37023)
* CLI: add read-only SecretRef inspection * CLI: fix read-only SecretRef status regressions * CLI: preserve read-only SecretRef status fallbacks * Docs: document read-only channel inspection hook * CLI: preserve audit coverage for read-only SecretRefs * CLI: fix read-only status account selection * CLI: fix targeted gateway fallback analysis * CLI: fix Slack HTTP read-only inspection * CLI: align audit credential status checks * CLI: restore Telegram read-only fallback semantics
This commit is contained in:
27
src/channels/account-snapshot-fields.test.ts
Normal file
27
src/channels/account-snapshot-fields.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { projectSafeChannelAccountSnapshotFields } from "./account-snapshot-fields.js";
|
||||
|
||||
describe("projectSafeChannelAccountSnapshotFields", () => {
|
||||
it("omits webhook and public-key style fields from generic snapshots", () => {
|
||||
const snapshot = projectSafeChannelAccountSnapshotFields({
|
||||
name: "Primary",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
webhookPath: "/webhook",
|
||||
audienceType: "project-number",
|
||||
audience: "1234567890",
|
||||
publicKey: "pk_live_123",
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
name: "Primary",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
});
|
||||
});
|
||||
});
|
||||
217
src/channels/account-snapshot-fields.ts
Normal file
217
src/channels/account-snapshot-fields.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
|
||||
|
||||
// Read-only status commands project a safe subset of account fields into snapshots
|
||||
// so renderers can preserve "configured but unavailable" state without touching
|
||||
// strict runtime-only credential helpers.
|
||||
|
||||
const CREDENTIAL_STATUS_KEYS = [
|
||||
"tokenStatus",
|
||||
"botTokenStatus",
|
||||
"appTokenStatus",
|
||||
"signingSecretStatus",
|
||||
"userTokenStatus",
|
||||
] as const;
|
||||
|
||||
type CredentialStatusKey = (typeof CREDENTIAL_STATUS_KEYS)[number];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readTrimmedString(record: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function readBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
|
||||
return typeof record[key] === "boolean" ? record[key] : undefined;
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
|
||||
const value = record[key];
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value
|
||||
.map((entry) => (typeof entry === "string" || typeof entry === "number" ? String(entry) : ""))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function readCredentialStatus(record: Record<string, unknown>, key: CredentialStatusKey) {
|
||||
const value = record[key];
|
||||
return value === "available" || value === "configured_unavailable" || value === "missing"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveConfiguredFromCredentialStatuses(account: unknown): boolean | undefined {
|
||||
const record = asRecord(account);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
let sawCredentialStatus = false;
|
||||
for (const key of CREDENTIAL_STATUS_KEYS) {
|
||||
const status = readCredentialStatus(record, key);
|
||||
if (!status) {
|
||||
continue;
|
||||
}
|
||||
sawCredentialStatus = true;
|
||||
if (status !== "missing") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return sawCredentialStatus ? false : undefined;
|
||||
}
|
||||
|
||||
export function resolveConfiguredFromRequiredCredentialStatuses(
|
||||
account: unknown,
|
||||
requiredKeys: CredentialStatusKey[],
|
||||
): boolean | undefined {
|
||||
const record = asRecord(account);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
let sawCredentialStatus = false;
|
||||
for (const key of requiredKeys) {
|
||||
const status = readCredentialStatus(record, key);
|
||||
if (!status) {
|
||||
continue;
|
||||
}
|
||||
sawCredentialStatus = true;
|
||||
if (status === "missing") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return sawCredentialStatus ? true : undefined;
|
||||
}
|
||||
|
||||
export function hasConfiguredUnavailableCredentialStatus(account: unknown): boolean {
|
||||
const record = asRecord(account);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
return CREDENTIAL_STATUS_KEYS.some(
|
||||
(key) => readCredentialStatus(record, key) === "configured_unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
export function hasResolvedCredentialValue(account: unknown): boolean {
|
||||
const record = asRecord(account);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
["token", "botToken", "appToken", "signingSecret", "userToken"].some((key) => {
|
||||
const value = record[key];
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}) || CREDENTIAL_STATUS_KEYS.some((key) => readCredentialStatus(record, key) === "available")
|
||||
);
|
||||
}
|
||||
|
||||
export function projectCredentialSnapshotFields(
|
||||
account: unknown,
|
||||
): Pick<
|
||||
Partial<ChannelAccountSnapshot>,
|
||||
| "tokenSource"
|
||||
| "botTokenSource"
|
||||
| "appTokenSource"
|
||||
| "signingSecretSource"
|
||||
| "tokenStatus"
|
||||
| "botTokenStatus"
|
||||
| "appTokenStatus"
|
||||
| "signingSecretStatus"
|
||||
| "userTokenStatus"
|
||||
> {
|
||||
const record = asRecord(account);
|
||||
if (!record) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
...(readTrimmedString(record, "tokenSource")
|
||||
? { tokenSource: readTrimmedString(record, "tokenSource") }
|
||||
: {}),
|
||||
...(readTrimmedString(record, "botTokenSource")
|
||||
? { botTokenSource: readTrimmedString(record, "botTokenSource") }
|
||||
: {}),
|
||||
...(readTrimmedString(record, "appTokenSource")
|
||||
? { appTokenSource: readTrimmedString(record, "appTokenSource") }
|
||||
: {}),
|
||||
...(readTrimmedString(record, "signingSecretSource")
|
||||
? { signingSecretSource: readTrimmedString(record, "signingSecretSource") }
|
||||
: {}),
|
||||
...(readCredentialStatus(record, "tokenStatus")
|
||||
? { tokenStatus: readCredentialStatus(record, "tokenStatus") }
|
||||
: {}),
|
||||
...(readCredentialStatus(record, "botTokenStatus")
|
||||
? { botTokenStatus: readCredentialStatus(record, "botTokenStatus") }
|
||||
: {}),
|
||||
...(readCredentialStatus(record, "appTokenStatus")
|
||||
? { appTokenStatus: readCredentialStatus(record, "appTokenStatus") }
|
||||
: {}),
|
||||
...(readCredentialStatus(record, "signingSecretStatus")
|
||||
? { signingSecretStatus: readCredentialStatus(record, "signingSecretStatus") }
|
||||
: {}),
|
||||
...(readCredentialStatus(record, "userTokenStatus")
|
||||
? { userTokenStatus: readCredentialStatus(record, "userTokenStatus") }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectSafeChannelAccountSnapshotFields(
|
||||
account: unknown,
|
||||
): Partial<ChannelAccountSnapshot> {
|
||||
const record = asRecord(account);
|
||||
if (!record) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
...(readTrimmedString(record, "name") ? { name: readTrimmedString(record, "name") } : {}),
|
||||
...(readBoolean(record, "linked") !== undefined
|
||||
? { linked: readBoolean(record, "linked") }
|
||||
: {}),
|
||||
...(readBoolean(record, "running") !== undefined
|
||||
? { running: readBoolean(record, "running") }
|
||||
: {}),
|
||||
...(readBoolean(record, "connected") !== undefined
|
||||
? { connected: readBoolean(record, "connected") }
|
||||
: {}),
|
||||
...(readNumber(record, "reconnectAttempts") !== undefined
|
||||
? { reconnectAttempts: readNumber(record, "reconnectAttempts") }
|
||||
: {}),
|
||||
...(readTrimmedString(record, "mode") ? { mode: readTrimmedString(record, "mode") } : {}),
|
||||
...(readTrimmedString(record, "dmPolicy")
|
||||
? { dmPolicy: readTrimmedString(record, "dmPolicy") }
|
||||
: {}),
|
||||
...(readStringArray(record, "allowFrom")
|
||||
? { allowFrom: readStringArray(record, "allowFrom") }
|
||||
: {}),
|
||||
...projectCredentialSnapshotFields(account),
|
||||
...(readTrimmedString(record, "baseUrl")
|
||||
? { baseUrl: readTrimmedString(record, "baseUrl") }
|
||||
: {}),
|
||||
...(readBoolean(record, "allowUnmentionedGroups") !== undefined
|
||||
? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") }
|
||||
: {}),
|
||||
...(readTrimmedString(record, "cliPath")
|
||||
? { cliPath: readTrimmedString(record, "cliPath") }
|
||||
: {}),
|
||||
...(readTrimmedString(record, "dbPath") ? { dbPath: readTrimmedString(record, "dbPath") } : {}),
|
||||
...(readNumber(record, "port") !== undefined ? { port: readNumber(record, "port") } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { projectSafeChannelAccountSnapshotFields } from "./account-snapshot-fields.js";
|
||||
import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
|
||||
import type { ChannelPlugin } from "./plugins/types.plugin.js";
|
||||
|
||||
@@ -14,6 +15,7 @@ export function buildChannelAccountSnapshot(params: {
|
||||
return {
|
||||
enabled: params.enabled,
|
||||
configured: params.configured,
|
||||
...projectSafeChannelAccountSnapshotFields(params.account),
|
||||
...described,
|
||||
accountId: params.accountId,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withEnv } from "../test-utils/env.js";
|
||||
import { getChannelDock } from "./dock.js";
|
||||
|
||||
function emptyConfig(): OpenClawConfig {
|
||||
@@ -69,7 +70,7 @@ describe("channels dock", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const accountDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "work" });
|
||||
const rootDefault = ircDock?.config?.resolveDefaultTo?.({ cfg, accountId: "missing" });
|
||||
@@ -99,4 +100,73 @@ describe("channels dock", () => {
|
||||
|
||||
expect(formatted).toEqual(["user", "foo", "plain"]);
|
||||
});
|
||||
|
||||
it("telegram dock config readers preserve omitted-account fallback semantics", () => {
|
||||
withEnv({ TELEGRAM_BOT_TOKEN: "tok-env" }, () => {
|
||||
const telegramDock = getChannelDock("telegram");
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["top-owner"],
|
||||
defaultTo: "@top-target",
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "tok-work",
|
||||
allowFrom: ["work-owner"],
|
||||
defaultTo: "@work-target",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(telegramDock?.config?.resolveAllowFrom?.({ cfg })).toEqual(["top-owner"]);
|
||||
expect(telegramDock?.config?.resolveDefaultTo?.({ cfg })).toBe("@top-target");
|
||||
});
|
||||
});
|
||||
|
||||
it("slack dock config readers stay read-only when tokens are unresolved SecretRefs", () => {
|
||||
const slackDock = getChannelDock("slack");
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SLACK_BOT_TOKEN",
|
||||
},
|
||||
appToken: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "SLACK_APP_TOKEN",
|
||||
},
|
||||
defaultTo: "channel:C111",
|
||||
dm: { allowFrom: ["U123"] },
|
||||
channels: {
|
||||
C111: { requireMention: false },
|
||||
},
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expect(slackDock?.config?.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["U123"]);
|
||||
expect(slackDock?.config?.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe(
|
||||
"channel:C111",
|
||||
);
|
||||
expect(
|
||||
slackDock?.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "channel",
|
||||
}),
|
||||
).toBe("all");
|
||||
expect(
|
||||
slackDock?.groups?.resolveRequireMention?.({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
groupId: "C111",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
resolveChannelGroupRequireMention,
|
||||
resolveChannelGroupToolsPolicy,
|
||||
} from "../config/group-policy.js";
|
||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||
import { inspectDiscordAccount } from "../discord/account-inspect.js";
|
||||
import {
|
||||
formatTrimmedAllowFromEntries,
|
||||
formatWhatsAppConfigAllowFromEntries,
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
import { requireActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
|
||||
import { inspectSlackAccount } from "../slack/account-inspect.js";
|
||||
import { resolveSlackReplyToMode } from "../slack/accounts.js";
|
||||
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
@@ -246,13 +247,13 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
stringifyAllowFrom(resolveTelegramAccount({ cfg, accountId }).config.allowFrom ?? []),
|
||||
stringifyAllowFrom(inspectTelegramAccount({ cfg, accountId }).config.allowFrom ?? []),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
trimAllowFromEntries(allowFrom)
|
||||
.map((entry) => entry.replace(/^(telegram|tg):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
resolveDefaultTo: ({ cfg, accountId }) => {
|
||||
const val = resolveTelegramAccount({ cfg, accountId }).config.defaultTo;
|
||||
const val = inspectTelegramAccount({ cfg, accountId }).config.defaultTo;
|
||||
return val != null ? String(val) : undefined;
|
||||
},
|
||||
},
|
||||
@@ -335,14 +336,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const account = resolveDiscordAccount({ cfg, accountId });
|
||||
const account = inspectDiscordAccount({ cfg, accountId });
|
||||
return (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map((entry) =>
|
||||
String(entry),
|
||||
);
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) => formatDiscordAllowFrom(allowFrom),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
resolveDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
||||
inspectDiscordAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveDiscordGroupRequireMention,
|
||||
@@ -477,14 +478,14 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
streaming: DEFAULT_BLOCK_STREAMING_COALESCE,
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const account = inspectSlackAccount({ cfg, accountId });
|
||||
return (account.config.allowFrom ?? account.dm?.allowFrom ?? []).map((entry) =>
|
||||
String(entry),
|
||||
);
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) => formatLower(allowFrom),
|
||||
resolveDefaultTo: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
||||
inspectSlackAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
@@ -495,7 +496,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||
resolveSlackReplyToMode(inspectSlackAccount({ cfg, accountId }), chatType),
|
||||
allowExplicitReplyTagsWhenOff: false,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenClawConfig } from "../../config/types.js";
|
||||
import { resolveDiscordAccount } from "../../discord/accounts.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
||||
import { inspectDiscordAccount } from "../../discord/account-inspect.js";
|
||||
import { inspectSlackAccount } from "../../slack/account-inspect.js";
|
||||
import { inspectTelegramAccount } from "../../telegram/account-inspect.js";
|
||||
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
||||
import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
|
||||
import { normalizeSlackMessagingTarget } from "./normalize/slack.js";
|
||||
@@ -75,7 +75,7 @@ function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirec
|
||||
export async function listSlackDirectoryPeersFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const ids = new Set<string>();
|
||||
|
||||
addAllowFromAndDmsIds(ids, account.config.allowFrom ?? account.dm?.allowFrom, account.config.dms);
|
||||
@@ -98,7 +98,7 @@ export async function listSlackDirectoryPeersFromConfig(
|
||||
export async function listSlackDirectoryGroupsFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const ids = Object.keys(account.config.channels ?? {})
|
||||
.map((raw) => raw.trim())
|
||||
.filter(Boolean)
|
||||
@@ -110,7 +110,7 @@ export async function listSlackDirectoryGroupsFromConfig(
|
||||
export async function listDiscordDirectoryPeersFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const ids = new Set<string>();
|
||||
|
||||
addAllowFromAndDmsIds(
|
||||
@@ -139,7 +139,7 @@ export async function listDiscordDirectoryPeersFromConfig(
|
||||
export async function listDiscordDirectoryGroupsFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const ids = new Set<string>();
|
||||
for (const guild of Object.values(account.config.guilds ?? {})) {
|
||||
addTrimmedEntries(ids, Object.keys(guild.channels ?? {}));
|
||||
@@ -159,7 +159,7 @@ export async function listDiscordDirectoryGroupsFromConfig(
|
||||
export async function listTelegramDirectoryPeersFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const raw = [
|
||||
...(account.config.allowFrom ?? []).map((entry) => String(entry)),
|
||||
...Object.keys(account.config.dms ?? {}),
|
||||
@@ -190,7 +190,7 @@ export async function listTelegramDirectoryPeersFromConfig(
|
||||
export async function listTelegramDirectoryGroupsFromConfig(
|
||||
params: DirectoryConfigParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const account = inspectTelegramAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const ids = Object.keys(account.config.groups ?? {})
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => Boolean(id) && id !== "*");
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
GroupToolPolicyConfig,
|
||||
} from "../../config/types.tools.js";
|
||||
import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { inspectSlackAccount } from "../../slack/account-inspect.js";
|
||||
import type { ChannelGroupContext } from "./types.js";
|
||||
|
||||
type GroupMentionParams = ChannelGroupContext;
|
||||
@@ -130,7 +130,7 @@ type ChannelGroupPolicyChannel =
|
||||
function resolveSlackChannelPolicyEntry(
|
||||
params: GroupMentionParams,
|
||||
): SlackChannelPolicyEntry | undefined {
|
||||
const account = resolveSlackAccount({
|
||||
const account = inspectSlackAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { DiscordGuildEntry } from "../../../config/types.discord.js";
|
||||
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
|
||||
import { inspectDiscordAccount } from "../../../discord/account-inspect.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
resolveDefaultDiscordAccountId,
|
||||
@@ -148,8 +149,8 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listDiscordAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveDiscordAccount({ cfg, accountId });
|
||||
return Boolean(account.token) || hasConfiguredSecretInput(account.config.token);
|
||||
const account = inspectDiscordAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
|
||||
import { inspectSlackAccount } from "../../../slack/account-inspect.js";
|
||||
import {
|
||||
listSlackAccountIds,
|
||||
resolveDefaultSlackAccountId,
|
||||
@@ -199,12 +200,8 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listSlackAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveSlackAccount({ cfg, accountId });
|
||||
const hasBotToken =
|
||||
Boolean(account.botToken) || hasConfiguredSecretInput(account.config.botToken);
|
||||
const hasAppToken =
|
||||
Boolean(account.appToken) || hasConfiguredSecretInput(account.config.appToken);
|
||||
return hasBotToken && hasAppToken;
|
||||
const account = inspectSlackAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { formatCliCommand } from "../../../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import { hasConfiguredSecretInput } from "../../../config/types.secrets.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
|
||||
import { inspectTelegramAccount } from "../../../telegram/account-inspect.js";
|
||||
import {
|
||||
listTelegramAccountIds,
|
||||
resolveDefaultTelegramAccountId,
|
||||
@@ -153,12 +154,8 @@ export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listTelegramAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveTelegramAccount({ cfg, accountId });
|
||||
return (
|
||||
Boolean(account.token) ||
|
||||
Boolean(account.config.tokenFile?.trim()) ||
|
||||
hasConfiguredSecretInput(account.config.botToken)
|
||||
);
|
||||
const account = inspectTelegramAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
createOutboundTestPlugin,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { withEnvAsync } from "../../test-utils/env.js";
|
||||
import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js";
|
||||
import { resolveChannelConfigWrites } from "./config-writes.js";
|
||||
import {
|
||||
@@ -409,6 +410,72 @@ describe("directory (config-backed)", () => {
|
||||
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
|
||||
});
|
||||
|
||||
it("keeps Telegram config-backed directory fallback semantics when accountId is omitted", async () => {
|
||||
await withEnvAsync({ TELEGRAM_BOT_TOKEN: "tok-env" }, async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["alice"],
|
||||
groups: { "-1001": {} },
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "tok-work",
|
||||
allowFrom: ["bob"],
|
||||
groups: { "-2002": {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
|
||||
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps config-backed directories readable when channel tokens are unresolved SecretRefs", async () => {
|
||||
const envSecret = {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_TEST_SECRET",
|
||||
} as const;
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: envSecret,
|
||||
appToken: envSecret,
|
||||
dm: { allowFrom: ["U123"] },
|
||||
channels: { C111: {} },
|
||||
},
|
||||
discord: {
|
||||
token: envSecret,
|
||||
dm: { allowFrom: ["<@111>"] },
|
||||
guilds: {
|
||||
"123": {
|
||||
channels: {
|
||||
"555": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
telegram: {
|
||||
botToken: envSecret,
|
||||
allowFrom: ["alice"],
|
||||
groups: { "-1001": {} },
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]);
|
||||
await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]);
|
||||
await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]);
|
||||
await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]);
|
||||
await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]);
|
||||
await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]);
|
||||
});
|
||||
|
||||
it("lists WhatsApp peers/groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -1,7 +1,70 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { projectSafeChannelAccountSnapshotFields } from "../account-snapshot-fields.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../read-only-account-inspect.js";
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "./types.js";
|
||||
|
||||
// Channel docking: status snapshots flow through plugin.status hooks here.
|
||||
async function buildSnapshotFromAccount<ResolvedAccount>(params: {
|
||||
plugin: ChannelPlugin<ResolvedAccount>;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
account: ResolvedAccount;
|
||||
runtime?: ChannelAccountSnapshot;
|
||||
probe?: unknown;
|
||||
audit?: unknown;
|
||||
}): Promise<ChannelAccountSnapshot> {
|
||||
if (params.plugin.status?.buildAccountSnapshot) {
|
||||
return await params.plugin.status.buildAccountSnapshot({
|
||||
account: params.account,
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
probe: params.probe,
|
||||
audit: params.audit,
|
||||
});
|
||||
}
|
||||
const enabled = params.plugin.config.isEnabled
|
||||
? params.plugin.config.isEnabled(params.account, params.cfg)
|
||||
: params.account && typeof params.account === "object"
|
||||
? (params.account as { enabled?: boolean }).enabled
|
||||
: undefined;
|
||||
const configured =
|
||||
params.account && typeof params.account === "object" && "configured" in params.account
|
||||
? (params.account as { configured?: boolean }).configured
|
||||
: params.plugin.config.isConfigured
|
||||
? await params.plugin.config.isConfigured(params.account, params.cfg)
|
||||
: undefined;
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
enabled,
|
||||
configured,
|
||||
...projectSafeChannelAccountSnapshotFields(params.account),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildReadOnlySourceChannelAccountSnapshot<ResolvedAccount>(params: {
|
||||
plugin: ChannelPlugin<ResolvedAccount>;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
runtime?: ChannelAccountSnapshot;
|
||||
probe?: unknown;
|
||||
audit?: unknown;
|
||||
}): Promise<ChannelAccountSnapshot | null> {
|
||||
const inspectedAccount =
|
||||
params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ??
|
||||
inspectReadOnlyChannelAccount({
|
||||
channelId: params.plugin.id,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!inspectedAccount) {
|
||||
return null;
|
||||
}
|
||||
return await buildSnapshotFromAccount({
|
||||
...params,
|
||||
account: inspectedAccount as ResolvedAccount,
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildChannelAccountSnapshot<ResolvedAccount>(params: {
|
||||
plugin: ChannelPlugin<ResolvedAccount>;
|
||||
cfg: OpenClawConfig;
|
||||
@@ -10,27 +73,17 @@ export async function buildChannelAccountSnapshot<ResolvedAccount>(params: {
|
||||
probe?: unknown;
|
||||
audit?: unknown;
|
||||
}): Promise<ChannelAccountSnapshot> {
|
||||
const account = params.plugin.config.resolveAccount(params.cfg, params.accountId);
|
||||
if (params.plugin.status?.buildAccountSnapshot) {
|
||||
return await params.plugin.status.buildAccountSnapshot({
|
||||
account,
|
||||
const inspectedAccount =
|
||||
params.plugin.config.inspectAccount?.(params.cfg, params.accountId) ??
|
||||
inspectReadOnlyChannelAccount({
|
||||
channelId: params.plugin.id,
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
probe: params.probe,
|
||||
audit: params.audit,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
const enabled = params.plugin.config.isEnabled
|
||||
? params.plugin.config.isEnabled(account, params.cfg)
|
||||
: account && typeof account === "object"
|
||||
? (account as { enabled?: boolean }).enabled
|
||||
: undefined;
|
||||
const configured = params.plugin.config.isConfigured
|
||||
? await params.plugin.config.isConfigured(account, params.cfg)
|
||||
: undefined;
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
enabled,
|
||||
configured,
|
||||
};
|
||||
const account = (inspectedAccount ??
|
||||
params.plugin.config.resolveAccount(params.cfg, params.accountId)) as ResolvedAccount;
|
||||
return await buildSnapshotFromAccount({
|
||||
...params,
|
||||
account,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export type ChannelSetupAdapter = {
|
||||
export type ChannelConfigAdapter<ResolvedAccount> = {
|
||||
listAccountIds: (cfg: OpenClawConfig) => string[];
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;
|
||||
inspectAccount?: (cfg: OpenClawConfig, accountId?: string | null) => unknown;
|
||||
defaultAccountId?: (cfg: OpenClawConfig) => string;
|
||||
setAccountEnabled?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
@@ -129,6 +129,12 @@ export type ChannelAccountSnapshot = {
|
||||
tokenSource?: string;
|
||||
botTokenSource?: string;
|
||||
appTokenSource?: string;
|
||||
signingSecretSource?: string;
|
||||
tokenStatus?: string;
|
||||
botTokenStatus?: string;
|
||||
appTokenStatus?: string;
|
||||
signingSecretStatus?: string;
|
||||
userTokenStatus?: string;
|
||||
credentialSource?: string;
|
||||
secretSource?: string;
|
||||
audienceType?: string;
|
||||
|
||||
39
src/channels/read-only-account-inspect.ts
Normal file
39
src/channels/read-only-account-inspect.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { inspectDiscordAccount, type InspectedDiscordAccount } from "../discord/account-inspect.js";
|
||||
import { inspectSlackAccount, type InspectedSlackAccount } from "../slack/account-inspect.js";
|
||||
import {
|
||||
inspectTelegramAccount,
|
||||
type InspectedTelegramAccount,
|
||||
} from "../telegram/account-inspect.js";
|
||||
import type { ChannelId } from "./plugins/types.js";
|
||||
|
||||
export type ReadOnlyInspectedAccount =
|
||||
| InspectedDiscordAccount
|
||||
| InspectedSlackAccount
|
||||
| InspectedTelegramAccount;
|
||||
|
||||
export function inspectReadOnlyChannelAccount(params: {
|
||||
channelId: ChannelId;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ReadOnlyInspectedAccount | null {
|
||||
if (params.channelId === "discord") {
|
||||
return inspectDiscordAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
if (params.channelId === "slack") {
|
||||
return inspectSlackAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
if (params.channelId === "telegram") {
|
||||
return inspectTelegramAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user