refactor: move googlechat to setup wizard

This commit is contained in:
Peter Steinberger 2026-03-15 17:56:45 -07:00
parent 8c71b36acb
commit 18e4e4677c
No known key found for this signature in database
4 changed files with 359 additions and 289 deletions

View File

@ -7,8 +7,6 @@ import {
formatNormalizedAllowFromEntries,
} from "openclaw/plugin-sdk/compat";
import {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
@ -16,9 +14,7 @@ import {
getChatChannelMeta,
listDirectoryGroupEntriesFromMapKeys,
listDirectoryUserEntriesFromAllowFrom,
migrateBaseNameToDefaultAccount,
missingTargetError,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveGoogleChatGroupRequireMention,
@ -40,8 +36,8 @@ import {
import { googlechatMessageActions } from "./actions.js";
import { sendGoogleChatMessage, uploadGoogleChatAttachment, probeGoogleChat } from "./api.js";
import { resolveGoogleChatWebhookPath, startGoogleChatMonitor } from "./monitor.js";
import { googlechatOnboardingAdapter } from "./onboarding.js";
import { getGoogleChatRuntime } from "./runtime.js";
import { googlechatSetupAdapter, googlechatSetupWizard } from "./setup-surface.js";
import {
isGoogleChatSpaceTarget,
isGoogleChatUserTarget,
@ -136,7 +132,8 @@ const googlechatActions: ChannelMessageActionAdapter = {
export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
id: "googlechat",
meta: { ...meta },
onboarding: googlechatOnboardingAdapter,
setup: googlechatSetupAdapter,
setupWizard: googlechatSetupWizard,
pairing: {
idLabel: "googlechatUserId",
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
@ -272,64 +269,6 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
},
},
actions: googlechatActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg,
channelKey: "googlechat",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.";
}
if (!input.useEnv && !input.token && !input.tokenFile) {
return "Google Chat requires --token (service account JSON) or --token-file.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg,
channelKey: "googlechat",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "googlechat",
})
: namedConfig;
const patch = input.useEnv
? {}
: input.tokenFile
? { serviceAccountFile: input.tokenFile }
: input.token
? { serviceAccount: input.token }
: {};
const audienceType = input.audienceType?.trim();
const audience = input.audience?.trim();
const webhookPath = input.webhookPath?.trim();
const webhookUrl = input.webhookUrl?.trim();
const configPatch = {
...patch,
...(audienceType ? { audienceType } : {}),
...(audience ? { audience } : {}),
...(webhookPath ? { webhookPath } : {}),
...(webhookUrl ? { webhookUrl } : {}),
};
return applySetupAccountConfigPatch({
cfg: next,
channelKey: "googlechat",
accountId,
patch: configPatch,
});
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),

View File

@ -1,225 +0,0 @@
import type { OpenClawConfig, DmPolicy } from "openclaw/plugin-sdk/googlechat";
import {
DEFAULT_ACCOUNT_ID,
applySetupAccountConfigPatch,
addWildcardAllowFrom,
formatDocsLink,
mergeAllowFromEntries,
resolveAccountIdForConfigure,
splitOnboardingEntries,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
migrateBaseNameToDefaultAccount,
} from "openclaw/plugin-sdk/googlechat";
import {
listGoogleChatAccountIds,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
} from "./accounts.js";
const channel = "googlechat" as const;
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) {
const allowFrom =
policy === "open"
? addWildcardAllowFrom(cfg.channels?.["googlechat"]?.dm?.allowFrom)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
googlechat: {
...cfg.channels?.["googlechat"],
dm: {
...cfg.channels?.["googlechat"]?.dm,
policy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
}
async function promptAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
}): Promise<OpenClawConfig> {
const current = params.cfg.channels?.["googlechat"]?.dm?.allowFrom ?? [];
const entry = await params.prompter.text({
message: "Google Chat allowFrom (users/<id> or raw email; avoid users/<email>)",
placeholder: "users/123456789, name@example.com",
initialValue: current[0] ? String(current[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = splitOnboardingEntries(String(entry));
const unique = mergeAllowFromEntries(undefined, parts);
return {
...params.cfg,
channels: {
...params.cfg.channels,
googlechat: {
...params.cfg.channels?.["googlechat"],
enabled: true,
dm: {
...params.cfg.channels?.["googlechat"]?.dm,
policy: "allowlist",
allowFrom: unique,
},
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Google Chat",
channel,
policyKey: "channels.googlechat.dm.policy",
allowFromKey: "channels.googlechat.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.["googlechat"]?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setGoogleChatDmPolicy(cfg, policy),
promptAllowFrom,
};
async function promptCredentials(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<OpenClawConfig> {
const { cfg, prompter, accountId } = params;
const envReady =
accountId === DEFAULT_ACCOUNT_ID &&
(Boolean(process.env[ENV_SERVICE_ACCOUNT]) || Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]));
if (envReady) {
const useEnv = await prompter.confirm({
message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?",
initialValue: true,
});
if (useEnv) {
return applySetupAccountConfigPatch({ cfg, channelKey: channel, accountId, patch: {} });
}
}
const method = await prompter.select({
message: "Google Chat auth method",
options: [
{ value: "file", label: "Service account JSON file" },
{ value: "inline", label: "Paste service account JSON" },
],
initialValue: "file",
});
if (method === "file") {
const path = await prompter.text({
message: "Service account JSON path",
placeholder: "/path/to/service-account.json",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
return applySetupAccountConfigPatch({
cfg,
channelKey: channel,
accountId,
patch: { serviceAccountFile: String(path).trim() },
});
}
const json = await prompter.text({
message: "Service account JSON (single line)",
placeholder: '{"type":"service_account", ... }',
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
return applySetupAccountConfigPatch({
cfg,
channelKey: channel,
accountId,
patch: { serviceAccount: String(json).trim() },
});
}
async function promptAudience(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<OpenClawConfig> {
const account = resolveGoogleChatAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const currentType = account.config.audienceType ?? "app-url";
const currentAudience = account.config.audience ?? "";
const audienceType = await params.prompter.select({
message: "Webhook audience type",
options: [
{ value: "app-url", label: "App URL (recommended)" },
{ value: "project-number", label: "Project number" },
],
initialValue: currentType === "project-number" ? "project-number" : "app-url",
});
const audience = await params.prompter.text({
message: audienceType === "project-number" ? "Project number" : "App URL",
placeholder: audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat",
initialValue: currentAudience || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
return applySetupAccountConfigPatch({
cfg: params.cfg,
channelKey: channel,
accountId: params.accountId,
patch: { audienceType, audience: String(audience).trim() },
});
}
async function noteGoogleChatSetup(prompter: WizardPrompter) {
await prompter.note(
[
"Google Chat apps use service-account auth and an HTTPS webhook.",
"Set the Chat API scopes in your service account and configure the Chat app URL.",
"Webhook verification requires audience type + audience value.",
`Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`,
].join("\n"),
"Google Chat setup",
);
}
export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const configured = listGoogleChatAccountIds(cfg).some(
(accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
);
return {
channel,
configured,
statusLines: [`Google Chat: ${configured ? "configured" : "needs service account"}`],
selectionHint: configured ? "configured" : "needs auth",
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
const defaultAccountId = resolveDefaultGoogleChatAccountId(cfg);
const accountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "Google Chat",
accountOverride: accountOverrides["googlechat"],
shouldPromptAccountIds,
listAccountIds: listGoogleChatAccountIds,
defaultAccountId,
});
let next = cfg;
await noteGoogleChatSetup(prompter);
next = await promptCredentials({ cfg: next, prompter, accountId });
next = await promptAudience({ cfg: next, prompter, accountId });
const namedConfig = migrateBaseNameToDefaultAccount({
cfg: next,
channelKey: "googlechat",
});
return { cfg: namedConfig, accountId };
},
};

View File

@ -0,0 +1,68 @@
import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/googlechat";
import { describe, expect, it, vi } from "vitest";
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { googlechatPlugin } from "./channel.js";
const selectFirstOption = async <T>(params: { options: Array<{ value: T }> }): Promise<T> => {
const first = params.options[0];
if (!first) {
throw new Error("no options");
}
return first.value;
};
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: selectFirstOption as WizardPrompter["select"],
multiselect: vi.fn(async () => []),
text: vi.fn(async () => "") as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
...overrides,
};
}
const googlechatConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({
plugin: googlechatPlugin,
wizard: googlechatPlugin.setupWizard!,
});
describe("googlechat setup wizard", () => {
it("configures service-account auth and webhook audience", async () => {
const prompter = createPrompter({
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Service account JSON path") {
return "/tmp/googlechat-service-account.json";
}
if (message === "App URL") {
return "https://example.com/googlechat";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
});
const runtime = createRuntimeEnv();
const result = await googlechatConfigureAdapter.configure({
cfg: {} as OpenClawConfig,
runtime,
prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.googlechat?.enabled).toBe(true);
expect(result.cfg.channels?.googlechat?.serviceAccountFile).toBe(
"/tmp/googlechat-service-account.json",
);
expect(result.cfg.channels?.googlechat?.audienceType).toBe("app-url");
expect(result.cfg.channels?.googlechat?.audience).toBe("https://example.com/googlechat");
});
});

View File

@ -0,0 +1,288 @@
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import {
addWildcardAllowFrom,
mergeAllowFromEntries,
setTopLevelChannelDmPolicyWithAllowFrom,
splitOnboardingEntries,
} from "../../../src/channels/plugins/onboarding/helpers.js";
import {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
migrateBaseNameToDefaultAccount,
} from "../../../src/channels/plugins/setup-helpers.js";
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DmPolicy } from "../../../src/config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import {
listGoogleChatAccountIds,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
} from "./accounts.js";
const channel = "googlechat" as const;
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const USE_ENV_FLAG = "__googlechatUseEnv";
const AUTH_METHOD_FLAG = "__googlechatAuthMethod";
function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) {
const allowFrom =
policy === "open" ? addWildcardAllowFrom(cfg.channels?.googlechat?.dm?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
googlechat: {
...cfg.channels?.googlechat,
dm: {
...cfg.channels?.googlechat?.dm,
policy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
}
async function promptAllowFrom(params: {
cfg: OpenClawConfig;
prompter: Parameters<NonNullable<ChannelOnboardingDmPolicy["promptAllowFrom"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
const current = params.cfg.channels?.googlechat?.dm?.allowFrom ?? [];
const entry = await params.prompter.text({
message: "Google Chat allowFrom (users/<id> or raw email; avoid users/<email>)",
placeholder: "users/123456789, name@example.com",
initialValue: current[0] ? String(current[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = splitOnboardingEntries(String(entry));
const unique = mergeAllowFromEntries(undefined, parts);
return {
...params.cfg,
channels: {
...params.cfg.channels,
googlechat: {
...params.cfg.channels?.googlechat,
enabled: true,
dm: {
...params.cfg.channels?.googlechat?.dm,
policy: "allowlist",
allowFrom: unique,
},
},
},
};
}
const googlechatDmPolicy: ChannelOnboardingDmPolicy = {
label: "Google Chat",
channel,
policyKey: "channels.googlechat.dm.policy",
allowFromKey: "channels.googlechat.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.googlechat?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setGoogleChatDmPolicy(cfg, policy),
promptAllowFrom,
};
export const googlechatSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.";
}
if (!input.useEnv && !input.token && !input.tokenFile) {
return "Google Chat requires --token (service account JSON) or --token-file.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
const patch = input.useEnv
? {}
: input.tokenFile
? { serviceAccountFile: input.tokenFile }
: input.token
? { serviceAccount: input.token }
: {};
const audienceType = input.audienceType?.trim();
const audience = input.audience?.trim();
const webhookPath = input.webhookPath?.trim();
const webhookUrl = input.webhookUrl?.trim();
return applySetupAccountConfigPatch({
cfg: next,
channelKey: channel,
accountId,
patch: {
...patch,
...(audienceType ? { audienceType } : {}),
...(audience ? { audience } : {}),
...(webhookPath ? { webhookPath } : {}),
...(webhookUrl ? { webhookUrl } : {}),
},
});
},
};
export const googlechatSetupWizard: ChannelSetupWizard = {
channel,
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs service account",
configuredHint: "configured",
unconfiguredHint: "needs auth",
resolveConfigured: ({ cfg }) =>
listGoogleChatAccountIds(cfg).some(
(accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
),
resolveStatusLines: ({ cfg }) => {
const configured = listGoogleChatAccountIds(cfg).some(
(accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
);
return [`Google Chat: ${configured ? "configured" : "needs service account"}`];
},
},
introNote: {
title: "Google Chat setup",
lines: [
"Google Chat apps use service-account auth and an HTTPS webhook.",
"Set the Chat API scopes in your service account and configure the Chat app URL.",
"Webhook verification requires audience type + audience value.",
`Docs: ${formatDocsLink("/channels/googlechat", "googlechat")}`,
],
},
prepare: async ({ cfg, accountId, credentialValues, prompter }) => {
const envReady =
accountId === DEFAULT_ACCOUNT_ID &&
(Boolean(process.env[ENV_SERVICE_ACCOUNT]) || Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]));
if (envReady) {
const useEnv = await prompter.confirm({
message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?",
initialValue: true,
});
if (useEnv) {
return {
cfg: applySetupAccountConfigPatch({
cfg,
channelKey: channel,
accountId,
patch: {},
}),
credentialValues: {
...credentialValues,
[USE_ENV_FLAG]: "1",
},
};
}
}
const method = await prompter.select({
message: "Google Chat auth method",
options: [
{ value: "file", label: "Service account JSON file" },
{ value: "inline", label: "Paste service account JSON" },
],
initialValue: "file",
});
return {
credentialValues: {
...credentialValues,
[USE_ENV_FLAG]: "0",
[AUTH_METHOD_FLAG]: String(method),
},
};
},
credentials: [],
textInputs: [
{
inputKey: "tokenFile",
message: "Service account JSON path",
placeholder: "/path/to/service-account.json",
shouldPrompt: ({ credentialValues }) =>
credentialValues[USE_ENV_FLAG] !== "1" && credentialValues[AUTH_METHOD_FLAG] === "file",
validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"),
normalizeValue: ({ value }) => String(value).trim(),
applySet: async ({ cfg, accountId, value }) =>
applySetupAccountConfigPatch({
cfg,
channelKey: channel,
accountId,
patch: { serviceAccountFile: value },
}),
},
{
inputKey: "token",
message: "Service account JSON (single line)",
placeholder: '{"type":"service_account", ... }',
shouldPrompt: ({ credentialValues }) =>
credentialValues[USE_ENV_FLAG] !== "1" && credentialValues[AUTH_METHOD_FLAG] === "inline",
validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"),
normalizeValue: ({ value }) => String(value).trim(),
applySet: async ({ cfg, accountId, value }) =>
applySetupAccountConfigPatch({
cfg,
channelKey: channel,
accountId,
patch: { serviceAccount: value },
}),
},
],
finalize: async ({ cfg, accountId, prompter }) => {
const account = resolveGoogleChatAccount({
cfg,
accountId,
});
const audienceType = await prompter.select({
message: "Webhook audience type",
options: [
{ value: "app-url", label: "App URL (recommended)" },
{ value: "project-number", label: "Project number" },
],
initialValue: account.config.audienceType === "project-number" ? "project-number" : "app-url",
});
const audience = await prompter.text({
message: audienceType === "project-number" ? "Project number" : "App URL",
placeholder:
audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat",
initialValue: account.config.audience || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
return {
cfg: migrateBaseNameToDefaultAccount({
cfg: applySetupAccountConfigPatch({
cfg,
channelKey: channel,
accountId,
patch: {
audienceType,
audience: String(audience).trim(),
},
}),
channelKey: channel,
}),
};
},
dmPolicy: googlechatDmPolicy,
};