feat: add gog oauth reuse for google chat

This commit is contained in:
iHildy 2026-01-26 17:21:09 -06:00
parent e83e4bf28c
commit e4591ca120
13 changed files with 304 additions and 13 deletions

View File

@ -49,7 +49,41 @@ Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
## User OAuth (optional, enables reactions)
Service accounts cover most bot workflows, but **reactions and user-attributed actions require user OAuth**.
1) Configure OAuth consent + create OAuth client credentials in your Google Cloud project.
### Option A: Use gog OAuth (recommended if you already use `gog`)
If you already use `gog` for Google Workspace, you can reuse its OAuth client + refresh token.
`gog` stores the OAuth client credentials JSON in your config directory and the refresh token in your system keyring. citeturn9view0turn6view0
1) Ensure `gog` is already authorized:
```bash
gog auth credentials /path/to/client_secret.json
gog auth add you@example.com --services gmail,calendar,drive,contacts,docs,sheets
```
2) Configure Google Chat to reuse `gog`:
```json5
{
channels: {
googlechat: {
oauthFromGog: true,
// Optional when multiple gog clients or accounts are configured:
gogAccount: "you@example.com",
gogClient: "work"
}
}
}
```
3) Ensure `gog` can access its keyring on the gateway host.
- `gog` stores refresh tokens in the system keychain by default. citeturn6view0
- For headless systems, switch to file keyring + password (see `gog` docs). citeturn6view0
Clawdbot reads `gog` OAuth client files from:
- `~/.config/gogcli/credentials.json`
- `~/.config/gogcli/credentials-<client>.json`
- `~/.config/gogcli/credentials-<domain>.json` (or macOS equivalent) citeturn9view0
Clawdbot queries `gog auth tokens --json` to reuse the stored refresh token. If this fails, set `oauthRefreshToken` manually.
### Option B: Manual OAuth
1) Configure OAuth consent + create OAuth client credentials in your Google Cloud project (desktop app recommended). citeturn6view0
2) Use an OAuth 2.0 flow to request **offline** access and collect a refresh token.
- Required scopes for reactions include:
- `https://www.googleapis.com/auth/chat.messages.reactions.create`
@ -160,6 +194,10 @@ Use these identifiers for delivery and allowlists:
// Optional: user OAuth for reactions + user-attributed actions
oauthClientFile: "/path/to/oauth-client.json",
oauthRefreshToken: "1//0g...",
// Or reuse gog:
// oauthFromGog: true,
// gogAccount: "you@example.com",
// gogClient: "work",
// Or explicit fields:
// oauthClientId: "123456.apps.googleusercontent.com",
// oauthClientSecret: "GOCSPX-...",
@ -195,6 +233,7 @@ Notes:
- Env options (default account): `GOOGLE_CHAT_OAUTH_CLIENT_ID`, `GOOGLE_CHAT_OAUTH_CLIENT_SECRET`,
`GOOGLE_CHAT_OAUTH_REDIRECT_URI`, `GOOGLE_CHAT_OAUTH_CLIENT_FILE`,
`GOOGLE_CHAT_OAUTH_REFRESH_TOKEN`, `GOOGLE_CHAT_OAUTH_REFRESH_TOKEN_FILE`.
- `oauthFromGog` reuses the `gog` keyring. Use `gogAccount`/`gogClient` (or `GOG_ACCOUNT`/`GOG_CLIENT`) when multiple accounts or clients exist.
- Default webhook path is `/googlechat` if `webhookPath` isnt set.
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).

View File

@ -1137,6 +1137,10 @@ Multi-account support lives under `channels.googlechat.accounts` (see the multi-
serviceAccountFile: "/path/to/service-account.json",
oauthClientFile: "/path/to/oauth-client.json",
oauthRefreshToken: "1//0g...",
// Or reuse gog OAuth:
// oauthFromGog: true,
// gogAccount: "you@example.com",
// gogClient: "work",
audienceType: "app-url", // app-url | project-number
audience: "https://gateway.example.com/googlechat",
webhookPath: "/googlechat",
@ -1166,6 +1170,7 @@ Notes:
`GOOGLE_CHAT_OAUTH_CLIENT_SECRET`, `GOOGLE_CHAT_OAUTH_REDIRECT_URI`,
`GOOGLE_CHAT_OAUTH_CLIENT_FILE`, `GOOGLE_CHAT_OAUTH_REFRESH_TOKEN`,
`GOOGLE_CHAT_OAUTH_REFRESH_TOKEN_FILE`.
- `oauthFromGog` reuses `gog` OAuth credentials; `gogAccount`/`gogClient` (or `GOG_ACCOUNT`/`GOG_CLIENT`) select the account/client.
- `audienceType` + `audience` must match the Chat apps webhook auth config.
- Use `spaces/<spaceId>` or `users/<userId|email>` when setting delivery targets.

View File

@ -2,6 +2,7 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.js";
import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js";
export type GoogleChatAppCredentialSource = "file" | "inline" | "env" | "none";
export type GoogleChatUserCredentialSource = "file" | "inline" | "env" | "none";
@ -21,6 +22,8 @@ export type ResolvedGoogleChatAccount = {
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const ENV_GOG_ACCOUNT = "GOG_ACCOUNT";
const ENV_GOG_CLIENT = "GOG_CLIENT";
const ENV_OAUTH_CLIENT_ID = "GOOGLE_CHAT_OAUTH_CLIENT_ID";
const ENV_OAUTH_CLIENT_SECRET = "GOOGLE_CHAT_OAUTH_CLIENT_SECRET";
const ENV_OAUTH_CLIENT_FILE = "GOOGLE_CHAT_OAUTH_CLIENT_FILE";
@ -121,6 +124,8 @@ function resolveUserAuthSource(params: {
account: GoogleChatAccountConfig;
}): GoogleChatUserCredentialSource {
const { account, accountId } = params;
const gogAccount = account.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined;
const gogClient = account.gogClient?.trim() || process.env[ENV_GOG_CLIENT]?.trim() || undefined;
const clientId = account.oauthClientId?.trim();
const clientSecret = account.oauthClientSecret?.trim();
const clientFile = account.oauthClientFile?.trim();
@ -131,6 +136,12 @@ function resolveUserAuthSource(params: {
const hasFileClient = hasNonEmptyString(clientFile);
const hasInlineRefresh = hasNonEmptyString(refreshToken);
const hasFileRefresh = hasNonEmptyString(refreshTokenFile);
const hasGogClient = account.oauthFromGog
? Boolean(resolveGogCredentialsFile({ gogClient, gogAccount }))
: false;
const hasGogRefresh = account.oauthFromGog
? Boolean(readGogRefreshTokenSync({ gogAccount, gogClient }))
: false;
const hasEnvClient =
accountId === DEFAULT_ACCOUNT_ID &&
@ -144,8 +155,10 @@ function resolveUserAuthSource(params: {
accountId === DEFAULT_ACCOUNT_ID &&
hasNonEmptyString(process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]);
const hasClient = hasInlineClient || hasFileClient || hasEnvClient || hasEnvClientFile;
const hasRefresh = hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile;
const hasClient =
hasInlineClient || hasFileClient || hasEnvClient || hasEnvClientFile || hasGogClient;
const hasRefresh =
hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile || hasGogRefresh;
if (!hasClient || !hasRefresh) return "none";
if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env";

View File

@ -4,6 +4,7 @@ import { GoogleAuth, OAuth2Client } from "google-auth-library";
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js";
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
const CHAT_ISSUER = "chat@system.gserviceaccount.com";
@ -65,6 +66,8 @@ const ENV_OAUTH_REDIRECT_URI = "GOOGLE_CHAT_OAUTH_REDIRECT_URI";
const ENV_OAUTH_CLIENT_FILE = "GOOGLE_CHAT_OAUTH_CLIENT_FILE";
const ENV_OAUTH_REFRESH_TOKEN = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN";
const ENV_OAUTH_REFRESH_TOKEN_FILE = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN_FILE";
const ENV_GOG_ACCOUNT = "GOG_ACCOUNT";
const ENV_GOG_CLIENT = "GOG_CLIENT";
type OAuthClientConfig = {
clientId: string;
@ -102,6 +105,8 @@ function readJsonFile(path: string): unknown | null {
function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClientConfig | null {
const cfg = account.config;
const gogAccount = cfg.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined;
const gogClient = cfg.gogClient?.trim() || process.env[ENV_GOG_CLIENT]?.trim() || undefined;
const inlineId = cfg.oauthClientId?.trim();
const inlineSecret = cfg.oauthClientSecret?.trim();
const inlineRedirect = cfg.oauthRedirectUri?.trim();
@ -119,6 +124,14 @@ function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClie
if (parsed) return parsed;
}
if (cfg.oauthFromGog) {
const gogCredentials = resolveGogCredentialsFile({ gogClient, gogAccount });
if (gogCredentials) {
const parsed = parseOAuthClientJson(readJsonFile(gogCredentials));
if (parsed) return parsed;
}
}
if (account.accountId === DEFAULT_ACCOUNT_ID) {
const envId = process.env[ENV_OAUTH_CLIENT_ID]?.trim();
const envSecret = process.env[ENV_OAUTH_CLIENT_SECRET]?.trim();
@ -138,6 +151,8 @@ function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClie
function resolveOAuthRefreshToken(account: ResolvedGoogleChatAccount): string | null {
const cfg = account.config;
const gogAccount = cfg.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined;
const gogClient = cfg.gogClient?.trim() || process.env[ENV_GOG_CLIENT]?.trim() || undefined;
if (cfg.oauthRefreshToken?.trim()) return cfg.oauthRefreshToken.trim();
const tokenFile = cfg.oauthRefreshTokenFile?.trim();
if (tokenFile) {
@ -154,6 +169,10 @@ function resolveOAuthRefreshToken(account: ResolvedGoogleChatAccount): string |
if (token) return token;
}
}
if (cfg.oauthFromGog) {
const token = readGogRefreshTokenSync({ gogAccount, gogClient });
if (token) return token;
}
if (account.accountId === DEFAULT_ACCOUNT_ID) {
const envToken = process.env[ENV_OAUTH_REFRESH_TOKEN]?.trim();
if (envToken) return envToken;

View File

@ -155,6 +155,9 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
"oauthClientFile",
"oauthRefreshToken",
"oauthRefreshTokenFile",
"oauthFromGog",
"gogAccount",
"gogClient",
"audienceType",
"audience",
"webhookPath",
@ -308,7 +311,8 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
}
const hasServiceAccount = Boolean(input.token || input.tokenFile);
const hasOauthInput = Boolean(
input.oauthClientId ||
input.oauthFromGog ||
input.oauthClientId ||
input.oauthClientSecret ||
input.oauthRedirectUri ||
input.oauthClientFile ||
@ -318,7 +322,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
if (!input.useEnv && !hasServiceAccount && !hasOauthInput) {
return "Google Chat requires service account JSON or OAuth credentials.";
}
if (hasOauthInput) {
if (hasOauthInput && !input.oauthFromGog) {
const hasClient =
Boolean(input.oauthClientFile) ||
(Boolean(input.oauthClientId) && Boolean(input.oauthClientSecret));
@ -356,6 +360,9 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
const oauthClientFile = input.oauthClientFile?.trim();
const oauthRefreshToken = input.oauthRefreshToken?.trim();
const oauthRefreshTokenFile = input.oauthRefreshTokenFile?.trim();
const oauthFromGog = input.oauthFromGog === true ? true : undefined;
const gogAccount = input.gogAccount?.trim();
const gogClient = input.gogClient?.trim();
const audienceType = input.audienceType?.trim();
const audience = input.audience?.trim();
const webhookPath = input.webhookPath?.trim();
@ -368,6 +375,9 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
...(oauthClientFile ? { oauthClientFile } : {}),
...(oauthRefreshToken ? { oauthRefreshToken } : {}),
...(oauthRefreshTokenFile ? { oauthRefreshTokenFile } : {}),
...(oauthFromGog ? { oauthFromGog } : {}),
...(gogAccount ? { gogAccount } : {}),
...(gogClient ? { gogClient } : {}),
...(audienceType ? { audienceType } : {}),
...(audience ? { audience } : {}),
...(webhookPath ? { webhookPath } : {}),

View File

@ -0,0 +1,153 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
type GogTokenEntry = {
account?: string;
refreshToken: string;
};
const tokenCache = new Map<string, string>();
function resolveConfigDirs(): string[] {
const dirs: string[] = [];
const xdg = process.env.XDG_CONFIG_HOME;
if (xdg) dirs.push(path.join(xdg, "gogcli"));
const home = os.homedir();
if (home) dirs.push(path.join(home, ".config", "gogcli"));
if (process.platform === "darwin" && home) {
dirs.push(path.join(home, "Library", "Application Support", "gogcli"));
}
if (process.platform === "win32") {
const appData = process.env.APPDATA;
if (appData) dirs.push(path.join(appData, "gogcli"));
}
return Array.from(new Set(dirs));
}
function extractDomain(account?: string | null): string | null {
const value = account?.trim();
if (!value) return null;
const at = value.lastIndexOf("@");
if (at === -1) return null;
return value.slice(at + 1).toLowerCase();
}
export function resolveGogCredentialsFile(params: {
gogClient?: string | null;
gogAccount?: string | null;
}): string | null {
const client = params.gogClient?.trim();
const account = params.gogAccount?.trim();
const domain = extractDomain(account);
const dirs = resolveConfigDirs();
const candidates: string[] = [];
if (client) {
for (const dir of dirs) {
candidates.push(path.join(dir, `credentials-${client}.json`));
}
}
if (domain) {
for (const dir of dirs) {
candidates.push(path.join(dir, `credentials-${domain}.json`));
}
}
for (const dir of dirs) {
candidates.push(path.join(dir, "credentials.json"));
}
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
function looksLikeRefreshToken(token: string): boolean {
const trimmed = token.trim();
if (!trimmed) return false;
if (trimmed.startsWith("ya29.")) return false;
if (trimmed.startsWith("1//")) return true;
return trimmed.length > 30;
}
function collectTokens(value: unknown, out: GogTokenEntry[]) {
if (!value || typeof value !== "object") return;
if (Array.isArray(value)) {
for (const entry of value) collectTokens(entry, out);
return;
}
const record = value as Record<string, unknown>;
const refreshToken =
typeof record.refresh_token === "string"
? record.refresh_token
: typeof record.refreshToken === "string"
? record.refreshToken
: undefined;
if (refreshToken && looksLikeRefreshToken(refreshToken)) {
const account =
typeof record.email === "string"
? record.email
: typeof record.account === "string"
? record.account
: typeof record.user === "string"
? record.user
: undefined;
out.push({ account, refreshToken });
}
for (const entry of Object.values(record)) {
collectTokens(entry, out);
}
}
export function readGogRefreshTokenSync(params: {
gogAccount?: string | null;
gogClient?: string | null;
}): string | null {
const cacheKey = `${params.gogClient ?? ""}:${params.gogAccount ?? ""}`;
const cached = tokenCache.get(cacheKey);
if (cached) return cached;
let stdout = "";
try {
stdout = execFileSync("gog", ["auth", "tokens", "--json"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
return null;
}
const tokens: GogTokenEntry[] = [];
collectTokens(parsed, tokens);
if (tokens.length === 0) return null;
const target = params.gogAccount?.trim().toLowerCase();
if (target) {
const match = tokens.find(
(entry) => entry.account?.trim().toLowerCase() === target,
);
if (match?.refreshToken) {
tokenCache.set(cacheKey, match.refreshToken);
return match.refreshToken;
}
}
if (tokens.length === 1) {
const only = tokens[0]?.refreshToken;
if (only) {
tokenCache.set(cacheKey, only);
return only;
}
}
return null;
}

View File

@ -218,14 +218,29 @@ async function promptOAuthCredentials(params: {
const method = await prompter.select({
message: "OAuth client source",
options: [
{ value: "gog", label: "Reuse gog OAuth (recommended if already set up)" },
{ value: "file", label: "OAuth client JSON file" },
{ value: "manual", label: "OAuth client id + secret" },
],
initialValue: "file",
initialValue: "gog",
});
let patch: Record<string, unknown> = {};
if (method === "file") {
if (method === "gog") {
const gogAccount = await prompter.text({
message: "gog account email (optional)",
placeholder: "you@example.com",
});
const gogClient = await prompter.text({
message: "gog client name (optional)",
placeholder: "work",
});
patch = {
oauthFromGog: true,
...(String(gogAccount ?? "").trim() ? { gogAccount: String(gogAccount).trim() } : {}),
...(String(gogClient ?? "").trim() ? { gogClient: String(gogClient).trim() } : {}),
};
} else if (method === "file") {
const path = await prompter.text({
message: "OAuth client JSON path",
placeholder: "/path/to/oauth-client.json",
@ -254,18 +269,21 @@ async function promptOAuthCredentials(params: {
};
}
const refreshToken = await prompter.text({
message: "OAuth refresh token",
placeholder: "1//0g...",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const refreshToken =
method === "gog"
? undefined
: await prompter.text({
message: "OAuth refresh token",
placeholder: "1//0g...",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
return applyAccountConfig({
cfg,
accountId,
patch: {
...patch,
oauthRefreshToken: String(refreshToken).trim(),
...(refreshToken ? { oauthRefreshToken: String(refreshToken).trim() } : {}),
},
});
}
@ -308,6 +326,7 @@ async function noteGoogleChatSetup(prompter: WizardPrompter) {
"Google Chat apps use service-account auth or user OAuth plus an HTTPS webhook.",
"Set the Chat API scopes in your service account and configure the Chat app URL.",
"User OAuth enables reactions and other user-level APIs.",
"If gog is configured, you can reuse its OAuth credentials for Chat.",
"Webhook verification requires audience type + audience value.",
`Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`,
].join("\n"),

View File

@ -41,6 +41,9 @@ export type ChannelSetupInput = {
oauthClientFile?: string;
oauthRefreshToken?: string;
oauthRefreshTokenFile?: string;
oauthFromGog?: boolean;
gogAccount?: string;
gogClient?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;

View File

@ -44,6 +44,9 @@ const optionNamesAdd = [
"oauthClientFile",
"oauthRefreshToken",
"oauthRefreshTokenFile",
"oauthFromGog",
"gogAccount",
"gogClient",
"useEnv",
"homeserver",
"userId",
@ -187,6 +190,9 @@ export function registerChannelsCli(program: Command) {
.option("--oauth-client-file <path>", "Google Chat OAuth client JSON file")
.option("--oauth-refresh-token <token>", "Google Chat OAuth refresh token")
.option("--oauth-refresh-token-file <path>", "Google Chat OAuth refresh token file")
.option("--oauth-from-gog", "Reuse gog OAuth credentials for Google Chat", false)
.option("--gog-account <email>", "gog account email to match refresh token")
.option("--gog-client <client>", "gog client name to match credentials file")
.option("--homeserver <url>", "Matrix homeserver URL")
.option("--user-id <id>", "Matrix user ID")
.option("--access-token <token>", "Matrix access token")

View File

@ -45,6 +45,9 @@ export function applyChannelAccountConfig(params: {
oauthClientFile?: string;
oauthRefreshToken?: string;
oauthRefreshTokenFile?: string;
oauthFromGog?: boolean;
gogAccount?: string;
gogClient?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;
@ -88,6 +91,9 @@ export function applyChannelAccountConfig(params: {
oauthClientFile: params.oauthClientFile,
oauthRefreshToken: params.oauthRefreshToken,
oauthRefreshTokenFile: params.oauthRefreshTokenFile,
oauthFromGog: params.oauthFromGog,
gogAccount: params.gogAccount,
gogClient: params.gogClient,
useEnv: params.useEnv,
homeserver: params.homeserver,
userId: params.userId,

View File

@ -42,6 +42,9 @@ export type ChannelsAddOptions = {
oauthClientFile?: string;
oauthRefreshToken?: string;
oauthRefreshTokenFile?: string;
oauthFromGog?: boolean;
gogAccount?: string;
gogClient?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;
@ -216,6 +219,9 @@ export async function channelsAddCommand(
oauthClientFile: opts.oauthClientFile,
oauthRefreshToken: opts.oauthRefreshToken,
oauthRefreshTokenFile: opts.oauthRefreshTokenFile,
oauthFromGog: opts.oauthFromGog,
gogAccount: opts.gogAccount,
gogClient: opts.gogClient,
homeserver: opts.homeserver,
userId: opts.userId,
accessToken: opts.accessToken,
@ -265,6 +271,9 @@ export async function channelsAddCommand(
oauthClientFile: opts.oauthClientFile,
oauthRefreshToken: opts.oauthRefreshToken,
oauthRefreshTokenFile: opts.oauthRefreshTokenFile,
oauthFromGog: opts.oauthFromGog,
gogAccount: opts.gogAccount,
gogClient: opts.gogClient,
homeserver: opts.homeserver,
userId: opts.userId,
accessToken: opts.accessToken,

View File

@ -72,6 +72,12 @@ export type GoogleChatAccountConfig = {
oauthRefreshToken?: string;
/** OAuth refresh token file path (user auth). */
oauthRefreshTokenFile?: string;
/** Reuse gog OAuth credentials (user auth). */
oauthFromGog?: boolean;
/** gog account email to match refresh token (optional). */
gogAccount?: string;
/** gog client name to match credentials file (optional). */
gogClient?: string;
/** Webhook audience type (app-url or project-number). */
audienceType?: "app-url" | "project-number";
/** Audience value (app URL or project number). */

View File

@ -317,6 +317,9 @@ export const GoogleChatAccountSchema = z
oauthClientFile: z.string().optional(),
oauthRefreshToken: z.string().optional(),
oauthRefreshTokenFile: z.string().optional(),
oauthFromGog: z.boolean().optional(),
gogAccount: z.string().optional(),
gogClient: z.string().optional(),
audienceType: z.enum(["app-url", "project-number"]).optional(),
audience: z.string().optional(),
webhookPath: z.string().optional(),