diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 3e721503a..3cd60b73f 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -5,9 +5,7 @@ read_when: --- # Google Chat (Chat API) -Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only). - -## Quick setup (beginner) +## Service Account Setup 1) Create a Google Cloud project and enable the **Google Chat API**. - Go to: [Google Chat API Credentials](https://console.cloud.google.com/apis/api/chat.googleapis.com/credentials) - Enable the API if it is not already enabled. @@ -46,6 +44,71 @@ Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only). 8) Set the webhook audience type + value (matches your Chat app config). 9) Start the gateway. Google Chat will POST to your webhook path. +## User OAuth (optional, enables reactions) +Service account covers almost all features, but **reactions and user-attributed actions require user OAuth**. + +### 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. + +1) Ensure `gog` is already authorized: + ```bash + gog auth list + ``` +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 (not inside `credentials.json`). + - For headless systems (systemd, SSH-only), switch to file keyring + password (see `gog` docs). + - Set `GOG_KEYRING_BACKEND=file` and `GOG_KEYRING_PASSWORD=...` for the gateway service. + - The file keyring lives under your gog config directory (for example `~/.config/gogcli/keyring/`). +4) Verify `gog` is visible to clawdbot and ask it to run: + ```bash + Run `gog auth tokens list --json` and tell me if you can access services. + ``` + This lists token keys only (no secrets). If this fails, install `gog` on the gateway host and ensure the keyring is accessible. + For non-interactive services, set `GOG_KEYRING_PASSWORD` in the gateway environment so `gog` can unlock the keyring. +5) Set `typingIndicator` to "reaction" in your clawdbot config. +```json5 +{ + channels: { + "googlechat": { + actions: { reactions: true }, + typingIndicator: "reaction", + } + } +} +``` + +Clawdbot reads `gog` OAuth client files from: +- `~/.config/gogcli/credentials.json` +- `~/.config/gogcli/credentials-.json` +- `~/.config/gogcli/credentials-.json` (or macOS equivalent) + +Clawdbot queries `gog auth tokens list --json` to discover which account to use, then runs `gog auth tokens export --out ` to read the refresh token. If you have multiple gog accounts, set `gogAccount` (or `GOG_ACCOUNT`) to pick the right one. 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). +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` + - `https://www.googleapis.com/auth/chat.messages.reactions` + - (or) `https://www.googleapis.com/auth/chat.messages` +3) Save the client credentials + refresh token in your config or env vars (examples below). + +**Tip:** user OAuth actions are attributed to the user in Google Chat. + ## Add to Google Chat Once the gateway is running and your email is added to the visibility list: 1) Go to [Google Chat](https://chat.google.com/). @@ -144,6 +207,17 @@ Use these identifiers for delivery and allowlists: "googlechat": { enabled: true, serviceAccountFile: "/path/to/service-account.json", + // 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-...", + // oauthRedirectUri: "https://your.host/googlechat/oauth/callback", audienceType: "app-url", audience: "https://gateway.example.com/googlechat", webhookPath: "/googlechat", @@ -171,6 +245,11 @@ Use these identifiers for delivery and allowlists: Notes: - Service account credentials can also be passed inline with `serviceAccount` (JSON string). +- User OAuth can be provided via `oauthClientFile` + `oauthRefreshToken` or the explicit client fields. +- 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` isn’t 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). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 5a00ea9cd..add47f45b 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1140,6 +1140,12 @@ Multi-account support lives under `channels.googlechat.accounts` (see the multi- "googlechat": { enabled: true, 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", @@ -1164,6 +1170,12 @@ Multi-account support lives under `channels.googlechat.accounts` (see the multi- Notes: - Service account JSON can be inline (`serviceAccount`) or file-based (`serviceAccountFile`). - Env fallbacks for the default account: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`. +- User OAuth can be provided via `oauthClientFile` + `oauthRefreshToken` (or explicit client fields). +- Env fallbacks for user OAuth (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 `gog` OAuth credentials; `gogAccount`/`gogClient` (or `GOG_ACCOUNT`/`GOG_CLIENT`) select the account/client. - `audienceType` + `audience` must match the Chat app’s webhook auth config. - Use `spaces/` or `users/` when setting delivery targets. diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index ade8a38ae..c8e5c7e26 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -2,8 +2,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.js"; +import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js"; -export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none"; +export type GoogleChatAppCredentialSource = "file" | "inline" | "env" | "none"; +export type GoogleChatUserCredentialSource = "file" | "inline" | "env" | "none"; +export type GoogleChatCredentialSource = "file" | "inline" | "env" | "oauth" | "none"; export type ResolvedGoogleChatAccount = { accountId: string; @@ -11,12 +14,21 @@ export type ResolvedGoogleChatAccount = { enabled: boolean; config: GoogleChatAccountConfig; credentialSource: GoogleChatCredentialSource; + appCredentialSource: GoogleChatAppCredentialSource; + userCredentialSource: GoogleChatUserCredentialSource; credentials?: Record; credentialsFile?: string; }; 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"; +const ENV_OAUTH_REFRESH_TOKEN = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN"; +const ENV_OAUTH_REFRESH_TOKEN_FILE = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN_FILE"; function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts; @@ -69,13 +81,17 @@ function parseServiceAccount(value: unknown): Record | null { } } +function hasNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + function resolveCredentialsFromConfig(params: { accountId: string; account: GoogleChatAccountConfig; }): { credentials?: Record; credentialsFile?: string; - source: GoogleChatCredentialSource; + source: GoogleChatAppCredentialSource; } { const { account, accountId } = params; const inline = parseServiceAccount(account.serviceAccount); @@ -103,6 +119,53 @@ function resolveCredentialsFromConfig(params: { return { source: "none" }; } +function resolveUserAuthSource(params: { + accountId: string; + 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(); + const refreshToken = account.oauthRefreshToken?.trim(); + const refreshTokenFile = account.oauthRefreshTokenFile?.trim(); + + const hasInlineClient = hasNonEmptyString(clientId) && hasNonEmptyString(clientSecret); + 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 && + hasNonEmptyString(process.env[ENV_OAUTH_CLIENT_ID]) && + hasNonEmptyString(process.env[ENV_OAUTH_CLIENT_SECRET]); + const hasEnvClientFile = + accountId === DEFAULT_ACCOUNT_ID && hasNonEmptyString(process.env[ENV_OAUTH_CLIENT_FILE]); + const hasEnvRefresh = + accountId === DEFAULT_ACCOUNT_ID && hasNonEmptyString(process.env[ENV_OAUTH_REFRESH_TOKEN]); + const hasEnvRefreshFile = + accountId === DEFAULT_ACCOUNT_ID && + hasNonEmptyString(process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]); + + const hasClient = + hasInlineClient || hasFileClient || hasEnvClient || hasEnvClientFile || hasGogClient; + const hasRefresh = + hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile || hasGogRefresh; + if (!hasClient || !hasRefresh) return "none"; + + if (hasFileClient || hasFileRefresh) return "file"; + if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env"; + return "inline"; +} + export function resolveGoogleChatAccount(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -114,13 +177,22 @@ export function resolveGoogleChatAccount(params: { const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; const credentials = resolveCredentialsFromConfig({ accountId, account: merged }); + const userCredentialSource = resolveUserAuthSource({ accountId, account: merged }); + const credentialSource = + credentials.source !== "none" + ? credentials.source + : userCredentialSource !== "none" + ? "oauth" + : "none"; return { accountId, name: merged.name?.trim() || undefined, enabled, config: merged, - credentialSource: credentials.source, + credentialSource, + appCredentialSource: credentials.source, + userCredentialSource, credentials: credentials.credentials, credentialsFile: credentials.credentialsFile, }; diff --git a/extensions/googlechat/src/actions.ts b/extensions/googlechat/src/actions.ts index cecce7b14..3053834fc 100644 --- a/extensions/googlechat/src/actions.ts +++ b/extensions/googlechat/src/actions.ts @@ -32,6 +32,7 @@ function listEnabledAccounts(cfg: OpenClawConfig) { function isReactionsEnabled(accounts: ReturnType, cfg: OpenClawConfig) { for (const account of accounts) { + if (account.userCredentialSource === "none") continue; const gate = createActionGate( (account.config.actions ?? (cfg.channels?.["googlechat"] as { actions?: unknown })?.actions) as Record< string, @@ -119,6 +120,9 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = { } if (action === "react") { + if (account.userCredentialSource === "none") { + throw new Error("Google Chat reactions require user OAuth credentials."); + } const messageName = readStringParam(params, "messageId", { required: true }); const { emoji, remove, isEmpty } = readReactionParams(params, { removeErrorMessage: "Emoji is required to remove a Google Chat reaction.", @@ -147,6 +151,9 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = { } if (action === "reactions") { + if (account.userCredentialSource === "none") { + throw new Error("Google Chat reactions require user OAuth credentials."); + } const messageName = readStringParam(params, "messageId", { required: true }); const limit = readNumberParam(params, "limit", { integer: true }); const reactions = await listGoogleChatReactions({ diff --git a/extensions/googlechat/src/api.test.ts b/extensions/googlechat/src/api.test.ts index 959b396df..9b0f1487d 100644 --- a/extensions/googlechat/src/api.test.ts +++ b/extensions/googlechat/src/api.test.ts @@ -11,6 +11,8 @@ const account = { accountId: "default", enabled: true, credentialSource: "inline", + appCredentialSource: "inline", + userCredentialSource: "none", config: {}, } as ResolvedGoogleChatAccount; diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index b487a2627..38be48a5f 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; -import { getGoogleChatAccessToken } from "./auth.js"; +import { getGoogleChatAccessToken, type GoogleChatAuthMode } from "./auth.js"; import type { GoogleChatReaction } from "./types.js"; const CHAT_API_BASE = "https://chat.googleapis.com/v1"; @@ -11,8 +11,9 @@ async function fetchJson( account: ResolvedGoogleChatAccount, url: string, init: RequestInit, + options?: { authMode?: GoogleChatAuthMode }, ): Promise { - const token = await getGoogleChatAccessToken(account); + const token = await getGoogleChatAccessToken(account, { mode: options?.authMode }); const res = await fetch(url, { ...init, headers: { @@ -32,8 +33,9 @@ async function fetchOk( account: ResolvedGoogleChatAccount, url: string, init: RequestInit, + options?: { authMode?: GoogleChatAuthMode }, ): Promise { - const token = await getGoogleChatAccessToken(account); + const token = await getGoogleChatAccessToken(account, { mode: options?.authMode }); const res = await fetch(url, { ...init, headers: { @@ -51,9 +53,9 @@ async function fetchBuffer( account: ResolvedGoogleChatAccount, url: string, init?: RequestInit, - options?: { maxBytes?: number }, + options?: { maxBytes?: number; authMode?: GoogleChatAuthMode }, ): Promise<{ buffer: Buffer; contentType?: string }> { - const token = await getGoogleChatAccessToken(account); + const token = await getGoogleChatAccessToken(account, { mode: options?.authMode }); const res = await fetch(url, { ...init, headers: { @@ -115,10 +117,14 @@ export async function sendGoogleChatMessage(params: { })); } const url = `${CHAT_API_BASE}/${space}/messages`; - const result = await fetchJson<{ name?: string }>(account, url, { - method: "POST", - body: JSON.stringify(body), - }); + const result = await fetchJson<{ name?: string }>( + account, + url, + { + method: "POST", + body: JSON.stringify(body), + }, + ); return result ? { messageName: result.name } : null; } @@ -129,10 +135,14 @@ export async function updateGoogleChatMessage(params: { }): Promise<{ messageName?: string }> { const { account, messageName, text } = params; const url = `${CHAT_API_BASE}/${messageName}?updateMask=text`; - const result = await fetchJson<{ name?: string }>(account, url, { - method: "PATCH", - body: JSON.stringify({ text }), - }); + const result = await fetchJson<{ name?: string }>( + account, + url, + { + method: "PATCH", + body: JSON.stringify({ text }), + }, + ); return { messageName: result.name }; } @@ -202,10 +212,15 @@ export async function createGoogleChatReaction(params: { }): Promise { const { account, messageName, emoji } = params; const url = `${CHAT_API_BASE}/${messageName}/reactions`; - return await fetchJson(account, url, { - method: "POST", - body: JSON.stringify({ emoji: { unicode: emoji } }), - }); + return await fetchJson( + account, + url, + { + method: "POST", + body: JSON.stringify({ emoji: { unicode: emoji } }), + }, + { authMode: "user" }, + ); } export async function listGoogleChatReactions(params: { @@ -216,9 +231,14 @@ export async function listGoogleChatReactions(params: { const { account, messageName, limit } = params; const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`); if (limit && limit > 0) url.searchParams.set("pageSize", String(limit)); - const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), { - method: "GET", - }); + const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>( + account, + url.toString(), + { + method: "GET", + }, + { authMode: "user" }, + ); return result.reactions ?? []; } @@ -228,7 +248,7 @@ export async function deleteGoogleChatReaction(params: { }): Promise { const { account, reactionName } = params; const url = `${CHAT_API_BASE}/${reactionName}`; - await fetchOk(account, url, { method: "DELETE" }); + await fetchOk(account, url, { method: "DELETE" }, { authMode: "user" }); } export async function findGoogleChatDirectMessage(params: { diff --git a/extensions/googlechat/src/auth.ts b/extensions/googlechat/src/auth.ts index 681ea6c22..64be9f723 100644 --- a/extensions/googlechat/src/auth.ts +++ b/extensions/googlechat/src/auth.ts @@ -1,6 +1,9 @@ import { GoogleAuth, OAuth2Client } from "google-auth-library"; +import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import { readJsonFile, readRefreshTokenFromFile } from "./file-utils.js"; +import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js"; const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot"; const CHAT_ISSUER = "chat@system.gserviceaccount.com"; @@ -10,6 +13,7 @@ const CHAT_CERTS_URL = "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com"; const authCache = new Map(); +const oauthCache = new Map(); const verifyClient = new OAuth2Client(); let cachedCerts: { fetchedAt: number; certs: Record } | null = null; @@ -42,7 +46,7 @@ function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth { return auth; } -export async function getGoogleChatAccessToken( +export async function getGoogleChatAppAccessToken( account: ResolvedGoogleChatAccount, ): Promise { const auth = getAuthInstance(account); @@ -55,6 +59,146 @@ export async function getGoogleChatAccessToken( return token; } +const ENV_OAUTH_CLIENT_ID = "GOOGLE_CHAT_OAUTH_CLIENT_ID"; +const ENV_OAUTH_CLIENT_SECRET = "GOOGLE_CHAT_OAUTH_CLIENT_SECRET"; +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; + clientSecret: string; + redirectUri?: string; +}; + +function parseOAuthClientJson(raw: unknown): OAuthClientConfig | null { + if (!raw || typeof raw !== "object") return null; + const record = raw as Record; + const container = + (record.web as Record | undefined) ?? + (record.installed as Record | undefined) ?? + record; + const clientId = typeof container.client_id === "string" ? container.client_id.trim() : ""; + const clientSecret = + typeof container.client_secret === "string" ? container.client_secret.trim() : ""; + const redirect = + Array.isArray(container.redirect_uris) && typeof container.redirect_uris[0] === "string" + ? container.redirect_uris[0].trim() + : ""; + if (!clientId || !clientSecret) return null; + return { clientId, clientSecret, redirectUri: redirect || undefined }; +} + +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(); + if (inlineId && inlineSecret) { + return { + clientId: inlineId, + clientSecret: inlineSecret, + redirectUri: inlineRedirect || undefined, + }; + } + + const filePath = cfg.oauthClientFile?.trim(); + if (filePath) { + const parsed = parseOAuthClientJson(readJsonFile(filePath)); + 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(); + const envRedirect = process.env[ENV_OAUTH_REDIRECT_URI]?.trim(); + if (envId && envSecret) { + return { clientId: envId, clientSecret: envSecret, redirectUri: envRedirect || undefined }; + } + const envFile = process.env[ENV_OAUTH_CLIENT_FILE]?.trim(); + if (envFile) { + const parsed = parseOAuthClientJson(readJsonFile(envFile)); + if (parsed) return parsed; + } + } + + return null; +} + +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) { + const token = readRefreshTokenFromFile(tokenFile); + 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; + const envFile = process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]?.trim(); + if (envFile) { + const token = readRefreshTokenFromFile(envFile); + if (token) return token; + } + } + return null; +} + +function getOAuthClient(account: ResolvedGoogleChatAccount): OAuth2Client { + const clientConfig = resolveOAuthClientConfig(account); + const refreshToken = resolveOAuthRefreshToken(account); + if (!clientConfig || !refreshToken) { + throw new Error("Missing Google Chat OAuth client credentials or refresh token"); + } + const key = `${clientConfig.clientId}:${clientConfig.clientSecret}:${clientConfig.redirectUri ?? ""}:${refreshToken}`; + const cached = oauthCache.get(account.accountId); + if (cached && cached.key === key) return cached.client; + + const client = new OAuth2Client( + clientConfig.clientId, + clientConfig.clientSecret, + clientConfig.redirectUri, + ); + client.setCredentials({ refresh_token: refreshToken }); + oauthCache.set(account.accountId, { key, client }); + return client; +} + +export async function getGoogleChatUserAccessToken( + account: ResolvedGoogleChatAccount, +): Promise { + const client = getOAuthClient(account); + const access = await client.getAccessToken(); + const token = typeof access === "string" ? access : access?.token; + if (!token) { + throw new Error("Missing Google Chat OAuth access token"); + } + return token; +} + async function fetchChatCerts(): Promise> { const now = Date.now(); if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) { @@ -71,6 +215,25 @@ async function fetchChatCerts(): Promise> { export type GoogleChatAudienceType = "app-url" | "project-number"; +export type GoogleChatAuthMode = "auto" | "app" | "user"; + +export async function getGoogleChatAccessToken( + account: ResolvedGoogleChatAccount, + options?: { mode?: GoogleChatAuthMode }, +): Promise { + const mode = options?.mode ?? "auto"; + if (mode === "user") { + return await getGoogleChatUserAccessToken(account); + } + if (mode === "app") { + return await getGoogleChatAppAccessToken(account); + } + if (account.appCredentialSource !== "none") { + return await getGoogleChatAppAccessToken(account); + } + return await getGoogleChatUserAccessToken(account); +} + export async function verifyGoogleChatRequest(params: { bearer?: string | null; audienceType?: GoogleChatAudienceType | null; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index da332abb9..2a587522c 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -149,6 +149,15 @@ export const googlechatPlugin: ChannelPlugin = { clearBaseFields: [ "serviceAccount", "serviceAccountFile", + "oauthClientId", + "oauthClientSecret", + "oauthRedirectUri", + "oauthClientFile", + "oauthRefreshToken", + "oauthRefreshTokenFile", + "oauthFromGog", + "gogAccount", + "gogClient", "audienceType", "audience", "webhookPath", @@ -158,6 +167,12 @@ export const googlechatPlugin: ChannelPlugin = { ], }), isConfigured: (account) => account.credentialSource !== "none", + unconfiguredReason: (account) => { + if (account.config.oauthFromGog) { + return "Google Chat OAuth from gog is enabled but no gog credentials were found. Ensure gog is installed, the gateway can access its keyring, or set oauthRefreshToken/oauthClientFile."; + } + return "Google Chat credentials are missing. Configure a service account or user OAuth."; + }, describeAccount: (account) => ({ accountId: account.accountId, name: account.name, @@ -298,10 +313,29 @@ export const googlechatPlugin: ChannelPlugin = { }), 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."; + return "Google Chat env credentials 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."; + const hasServiceAccount = Boolean(input.token || input.tokenFile); + const hasOauthInput = Boolean( + input.oauthFromGog || + input.oauthClientId || + input.oauthClientSecret || + input.oauthRedirectUri || + input.oauthClientFile || + input.oauthRefreshToken || + input.oauthRefreshTokenFile, + ); + if (!input.useEnv && !hasServiceAccount && !hasOauthInput) { + return "Google Chat requires service account JSON or OAuth credentials."; + } + if (hasOauthInput && !input.oauthFromGog) { + const hasClient = + Boolean(input.oauthClientFile) || + (Boolean(input.oauthClientId) && Boolean(input.oauthClientSecret)); + const hasRefresh = Boolean(input.oauthRefreshToken || input.oauthRefreshTokenFile); + if (!hasClient || !hasRefresh) { + return "Google Chat OAuth requires client id/secret (or --oauth-client-file) and a refresh token."; + } } return null; }, @@ -326,12 +360,30 @@ export const googlechatPlugin: ChannelPlugin = { : input.token ? { serviceAccount: input.token } : {}; + const oauthClientId = input.oauthClientId?.trim(); + const oauthClientSecret = input.oauthClientSecret?.trim(); + const oauthRedirectUri = input.oauthRedirectUri?.trim(); + 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(); const webhookUrl = input.webhookUrl?.trim(); const configPatch = { ...patch, + ...(oauthClientId ? { oauthClientId } : {}), + ...(oauthClientSecret ? { oauthClientSecret } : {}), + ...(oauthRedirectUri ? { oauthRedirectUri } : {}), + ...(oauthClientFile ? { oauthClientFile } : {}), + ...(oauthRefreshToken ? { oauthRefreshToken } : {}), + ...(oauthRefreshTokenFile ? { oauthRefreshTokenFile } : {}), + ...(oauthFromGog ? { oauthFromGog } : {}), + ...(gogAccount ? { gogAccount } : {}), + ...(gogClient ? { gogClient } : {}), ...(audienceType ? { audienceType } : {}), ...(audience ? { audience } : {}), ...(webhookPath ? { webhookPath } : {}), @@ -491,6 +543,16 @@ export const googlechatPlugin: ChannelPlugin = { const configured = entry.configured === true; if (!enabled || !configured) return []; const issues = []; + if (entry.oauthFromGog && entry.userCredentialSource === "none") { + issues.push({ + channel: "googlechat", + accountId, + kind: "auth", + message: + "Google Chat OAuth is set to reuse gog, but no gog OAuth credentials were detected.", + fix: "Ensure gog is installed and the keyring is unlocked (set GOG_KEYRING_PASSWORD), or set oauthRefreshToken/oauthClientFile manually.", + }); + } if (!entry.audience) { issues.push({ channel: "googlechat", @@ -532,6 +594,8 @@ export const googlechatPlugin: ChannelPlugin = { enabled: account.enabled, configured: account.credentialSource !== "none", credentialSource: account.credentialSource, + oauthFromGog: account.config.oauthFromGog ?? false, + userCredentialSource: account.userCredentialSource, audienceType: account.config.audienceType, audience: account.config.audience, webhookPath: account.config.webhookPath, diff --git a/extensions/googlechat/src/file-utils.ts b/extensions/googlechat/src/file-utils.ts new file mode 100644 index 000000000..1c0c898b5 --- /dev/null +++ b/extensions/googlechat/src/file-utils.ts @@ -0,0 +1,48 @@ +import fs from "node:fs"; + +/** + * Reads and parses a JSON file. Returns null if the file doesn't exist, + * is empty, or cannot be parsed. Logs meaningful errors for debugging. + */ +export function readJsonFile(filePath: string): unknown | null { + try { + const raw = fs.readFileSync(filePath, "utf8"); + if (!raw.trim()) return null; + return JSON.parse(raw); + } catch (err) { + // Log meaningful errors (permission issues, malformed JSON) for debugging + if (err instanceof Error && err.message && !err.message.includes("ENOENT")) { + console.error(`Failed to read or parse JSON file ${filePath}`); + } + return null; + } +} + +/** + * Extracts a refresh token from a JSON object that may have either + * `refresh_token` or `refreshToken` property. + */ +export function extractRefreshTokenFromRecord(record: Record): string | null { + const token = + typeof record.refresh_token === "string" + ? record.refresh_token.trim() + : typeof record.refreshToken === "string" + ? record.refreshToken.trim() + : undefined; + if (token && token.length > 0) return token; + return null; +} + +/** + * Reads a refresh token from a file. The file may contain either: + * - A plain string token + * - A JSON object with refresh_token or refreshToken property + */ +export function readRefreshTokenFromFile(filePath: string): string | null { + const raw = readJsonFile(filePath); + if (typeof raw === "string" && raw.trim()) return raw.trim(); + if (raw && typeof raw === "object") { + return extractRefreshTokenFromRecord(raw as Record); + } + return null; +} diff --git a/extensions/googlechat/src/gog.ts b/extensions/googlechat/src/gog.ts new file mode 100644 index 000000000..b7add3b75 --- /dev/null +++ b/extensions/googlechat/src/gog.ts @@ -0,0 +1,218 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { readJsonFile } from "./file-utils.js"; + +const tokenCache = new Map(); + +function resolveWildcardJsonFile( + dirs: string[], + baseName: string, + suffix = ".json", +): string | null { + const matches: string[] = []; + for (const dir of dirs) { + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if ( + !entry.name.startsWith(`${baseName}-`) || + !entry.name.endsWith(suffix) + ) + continue; + matches.push(path.join(dir, entry.name)); + } + } catch { + // Ignore missing/permission issues and fall back to other dirs. + } + } + if (matches.length === 1) return matches[0]; + return null; +} + +function resolveGogJsonFile( + params: { gogClient?: string | null; gogAccount?: string | null }, + baseName: string, +): 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, `${baseName}-${client}.json`)); + } + } + if (domain) { + for (const dir of dirs) { + candidates.push(path.join(dir, `${baseName}-${domain}.json`)); + } + } + for (const dir of dirs) { + candidates.push(path.join(dir, `${baseName}.json`)); + } + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + return resolveWildcardJsonFile(dirs, baseName); +} + +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 { + return resolveGogJsonFile(params, "credentials"); +} + +function extractRefreshToken(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const refreshToken = + typeof record.refresh_token === "string" + ? record.refresh_token + : typeof record.refreshToken === "string" + ? record.refreshToken + : undefined; + const trimmed = refreshToken?.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("ya29.")) return null; + if (trimmed.startsWith("1//")) return trimmed; + return trimmed.length > 30 ? trimmed : null; +} + +function parseTokenEmails(value: unknown): string[] { + if (!value || typeof value !== "object") return []; + const record = value as Record; + const keys = Array.isArray(record.keys) + ? record.keys.filter((entry): entry is string => typeof entry === "string") + : []; + const emails = new Set(); + for (const key of keys) { + const email = parseTokenEmail(key); + if (email) emails.add(email); + } + return Array.from(emails); +} + +function parseTokenEmail(key: string): string | null { + const trimmed = key.trim(); + if (!trimmed) return null; + const parts = trimmed.split(":"); + if (parts.length < 2) return null; + if (parts[0] !== "token") return null; + if (parts.length === 2) return parts[1] || null; + return parts[2] || null; +} + +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; + + const env = { + ...process.env, + ...(params.gogAccount?.trim() + ? { GOG_ACCOUNT: params.gogAccount.trim() } + : {}), + ...(params.gogClient?.trim() + ? { GOG_CLIENT: params.gogClient.trim() } + : {}), + }; + + const runGogJson = (args: string[]): unknown | null => { + try { + const stdout = execFileSync("gog", ["--no-input", "--json", ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 3000, + env, + }); + return JSON.parse(stdout); + } catch { + return null; + } + }; + + const explicitAccount = params.gogAccount?.trim(); + let account = explicitAccount; + if (!account) { + const parsed = runGogJson(["auth", "tokens", "list"]); + const emails = parseTokenEmails(parsed); + if (emails.length === 1) { + account = emails[0]; + } else { + return null; + } + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gog-")); + const outPath = path.join(tmpDir, "token.json"); + try { + execFileSync( + "gog", + [ + "--no-input", + "--json", + "auth", + "tokens", + "export", + account, + "--out", + outPath, + "--overwrite", + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 5000, + env, + }, + ); + const parsed = readJsonFile(outPath); + const token = extractRefreshToken(parsed); + if (!token) return null; + tokenCache.set(cacheKey, token); + return token; + } catch { + return null; + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } +} diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index d8ada7566..7d6058309 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -7,8 +7,10 @@ import { type ResolvedGoogleChatAccount } from "./accounts.js"; import { + createGoogleChatReaction, downloadGoogleChatMedia, deleteGoogleChatMessage, + deleteGoogleChatReaction, sendGoogleChatMessage, updateGoogleChatMessage, } from "./api.js"; @@ -642,18 +644,18 @@ async function processMessageWithPipeline(params: { }); // Typing indicator setup - // Note: Reaction mode requires user OAuth, not available with service account auth. - // If reaction is configured, we fall back to message mode with a warning. + // Note: Reaction mode requires user OAuth. If unavailable, fall back to message mode. let typingIndicator = account.config.typingIndicator ?? "message"; - if (typingIndicator === "reaction") { + if (typingIndicator === "reaction" && account.userCredentialSource === "none") { runtime.error?.( - `[${account.accountId}] typingIndicator="reaction" requires user OAuth (not supported with service account). Falling back to "message" mode.`, + `[${account.accountId}] typingIndicator="reaction" requires user OAuth. Configure OAuth (or oauthFromGog) to enable reactions; falling back to "message" mode.`, ); typingIndicator = "message"; } let typingMessageName: string | undefined; + let typingReactionName: string | undefined; - // Start typing indicator (message mode only, reaction mode not supported with app auth) + // Start typing indicator (message mode uses a temporary message; reaction mode uses 👀) if (typingIndicator === "message") { try { const botName = resolveBotDisplayName({ @@ -673,6 +675,21 @@ async function processMessageWithPipeline(params: { } } + if (typingIndicator === "reaction" && account.userCredentialSource !== "none") { + if (message.name) { + try { + const reaction = await createGoogleChatReaction({ + account, + messageName: message.name, + emoji: "👀", + }); + typingReactionName = reaction?.name; + } catch (err) { + runtime.error?.(`Failed sending typing reaction: ${String(err)}`); + } + } + } + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, @@ -687,9 +704,11 @@ async function processMessageWithPipeline(params: { config, statusSink, typingMessageName, + typingReactionName, }); // Only use typing message for first delivery typingMessageName = undefined; + typingReactionName = undefined; }, onError: (err, info) => { runtime.error?.( @@ -729,14 +748,36 @@ async function deliverGoogleChatReply(params: { config: OpenClawConfig; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; typingMessageName?: string; + typingReactionName?: string; }): Promise { - const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params; + const { + payload, + account, + spaceId, + runtime, + core, + config, + statusSink, + typingMessageName, + typingReactionName, + } = params; const mediaList = payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; + if (typingReactionName) { + try { + await deleteGoogleChatReaction({ + account, + reactionName: typingReactionName, + }); + } catch (err) { + runtime.error?.(`Google Chat typing reaction cleanup failed: ${String(err)}`); + } + } + if (mediaList.length > 0) { let suppressCaption = false; if (typingMessageName) { diff --git a/extensions/googlechat/src/onboarding.ts b/extensions/googlechat/src/onboarding.ts index f25a64595..b05a732ae 100644 --- a/extensions/googlechat/src/onboarding.ts +++ b/extensions/googlechat/src/onboarding.ts @@ -21,6 +21,11 @@ const channel = "googlechat" as const; const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT"; const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; +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"; +const ENV_OAUTH_REFRESH_TOKEN = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN"; +const ENV_OAUTH_REFRESH_TOKEN_FILE = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN_FILE"; function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) { const allowFrom = @@ -138,10 +143,15 @@ async function promptCredentials(params: { const envReady = accountId === DEFAULT_ACCOUNT_ID && (Boolean(process.env[ENV_SERVICE_ACCOUNT]) || - Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE])); + Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]) || + Boolean(process.env[ENV_OAUTH_CLIENT_ID]) || + Boolean(process.env[ENV_OAUTH_CLIENT_SECRET]) || + Boolean(process.env[ENV_OAUTH_CLIENT_FILE]) || + Boolean(process.env[ENV_OAUTH_REFRESH_TOKEN]) || + Boolean(process.env[ENV_OAUTH_REFRESH_TOKEN_FILE])); if (envReady) { const useEnv = await prompter.confirm({ - message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?", + message: "Use Google Chat env credentials?", initialValue: true, }); if (useEnv) { @@ -183,6 +193,101 @@ async function promptCredentials(params: { }); } +async function promptOAuthCredentials(params: { + cfg: ClawdbotConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const { cfg, prompter, accountId } = params; + const envReady = + accountId === DEFAULT_ACCOUNT_ID && + (Boolean(process.env[ENV_OAUTH_CLIENT_ID]) || + Boolean(process.env[ENV_OAUTH_CLIENT_SECRET]) || + Boolean(process.env[ENV_OAUTH_CLIENT_FILE]) || + Boolean(process.env[ENV_OAUTH_REFRESH_TOKEN]) || + Boolean(process.env[ENV_OAUTH_REFRESH_TOKEN_FILE])); + if (envReady) { + const useEnv = await prompter.confirm({ + message: "Use Google Chat OAuth env credentials?", + initialValue: true, + }); + if (useEnv) { + return applyAccountConfig({ cfg, accountId, patch: {} }); + } + } + 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: "gog", + }); + + let patch: Record = {}; + 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", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + patch = { oauthClientFile: String(path).trim() }; + } else { + const clientId = await prompter.text({ + message: "OAuth client id", + placeholder: "123456.apps.googleusercontent.com", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const clientSecret = await prompter.text({ + message: "OAuth client secret", + placeholder: "GOCSPX-...", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const redirectUri = await prompter.text({ + message: "OAuth redirect URI (optional)", + placeholder: "https://your.host/googlechat/oauth/callback", + }); + patch = { + oauthClientId: String(clientId).trim(), + oauthClientSecret: String(clientSecret).trim(), + ...(String(redirectUri ?? "").trim() ? { oauthRedirectUri: String(redirectUri).trim() } : {}), + }; + } + + 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, + ...(refreshToken ? { oauthRefreshToken: String(refreshToken).trim() } : {}), + }, + }); +} + async function promptAudience(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -218,8 +323,10 @@ async function promptAudience(params: { async function noteGoogleChatSetup(prompter: WizardPrompter) { await prompter.note( [ - "Google Chat apps use service-account auth and an HTTPS webhook.", + "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"), @@ -238,7 +345,7 @@ export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = { channel, configured, statusLines: [ - `Google Chat: ${configured ? "configured" : "needs service account"}`, + `Google Chat: ${configured ? "configured" : "needs auth"}`, ], selectionHint: configured ? "configured" : "needs auth", }; @@ -265,7 +372,19 @@ export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = { let next = cfg; await noteGoogleChatSetup(prompter); - next = await promptCredentials({ cfg: next, prompter, accountId }); + const authMethod = await prompter.select({ + message: "Configure Google Chat credentials", + options: [ + { value: "service-account", label: "Service account (bot auth)" }, + { value: "oauth", label: "User OAuth (reactions + user actions)" }, + ], + initialValue: "service-account", + }); + if (authMethod === "oauth") { + next = await promptOAuthCredentials({ cfg: next, prompter, accountId }); + } else { + next = await promptCredentials({ cfg: next, prompter, accountId }); + } next = await promptAudience({ cfg: next, prompter, accountId }); const namedConfig = migrateBaseNameToDefaultAccount({ diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index d640b389f..90462fd82 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -35,6 +35,15 @@ export type ChannelSetupInput = { webhookUrl?: string; audienceType?: string; audience?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthRedirectUri?: string; + oauthClientFile?: string; + oauthRefreshToken?: string; + oauthRefreshTokenFile?: string; + oauthFromGog?: boolean; + gogAccount?: string; + gogClient?: string; useEnv?: boolean; homeserver?: string; userId?: string; diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index dd60016d4..035c9690e 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -38,6 +38,15 @@ const optionNamesAdd = [ "webhookUrl", "audienceType", "audience", + "oauthClientId", + "oauthClientSecret", + "oauthRedirectUri", + "oauthClientFile", + "oauthRefreshToken", + "oauthRefreshTokenFile", + "oauthFromGog", + "gogAccount", + "gogClient", "useEnv", "homeserver", "userId", @@ -175,6 +184,15 @@ export function registerChannelsCli(program: Command) { .option("--webhook-url ", "Google Chat webhook URL") .option("--audience-type ", "Google Chat audience type (app-url|project-number)") .option("--audience ", "Google Chat audience value (app URL or project number)") + .option("--oauth-client-id ", "Google Chat OAuth client ID") + .option("--oauth-client-secret ", "Google Chat OAuth client secret") + .option("--oauth-redirect-uri ", "Google Chat OAuth redirect URI") + .option("--oauth-client-file ", "Google Chat OAuth client JSON file") + .option("--oauth-refresh-token ", "Google Chat OAuth refresh token") + .option("--oauth-refresh-token-file ", "Google Chat OAuth refresh token file") + .option("--oauth-from-gog", "Reuse gog OAuth credentials for Google Chat", false) + .option("--gog-account ", "gog account email to match refresh token") + .option("--gog-client ", "gog client name to match credentials file") .option("--homeserver ", "Matrix homeserver URL") .option("--user-id ", "Matrix user ID") .option("--access-token ", "Matrix access token") diff --git a/src/commands/channels/add-mutators.ts b/src/commands/channels/add-mutators.ts index f6d9d3a56..fc5d8778c 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -39,6 +39,15 @@ export function applyChannelAccountConfig(params: { webhookUrl?: string; audienceType?: string; audience?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthRedirectUri?: string; + oauthClientFile?: string; + oauthRefreshToken?: string; + oauthRefreshTokenFile?: string; + oauthFromGog?: boolean; + gogAccount?: string; + gogClient?: string; useEnv?: boolean; homeserver?: string; userId?: string; @@ -76,6 +85,15 @@ export function applyChannelAccountConfig(params: { webhookUrl: params.webhookUrl, audienceType: params.audienceType, audience: params.audience, + oauthClientId: params.oauthClientId, + oauthClientSecret: params.oauthClientSecret, + oauthRedirectUri: params.oauthRedirectUri, + 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, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 274df1775..bc1dc8d61 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -36,6 +36,15 @@ export type ChannelsAddOptions = { webhookUrl?: string; audienceType?: string; audience?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthRedirectUri?: string; + oauthClientFile?: string; + oauthRefreshToken?: string; + oauthRefreshTokenFile?: string; + oauthFromGog?: boolean; + gogAccount?: string; + gogClient?: string; useEnv?: boolean; homeserver?: string; userId?: string; @@ -204,6 +213,15 @@ export async function channelsAddCommand( webhookUrl: opts.webhookUrl, audienceType: opts.audienceType, audience: opts.audience, + oauthClientId: opts.oauthClientId, + oauthClientSecret: opts.oauthClientSecret, + oauthRedirectUri: opts.oauthRedirectUri, + 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, @@ -247,6 +265,15 @@ export async function channelsAddCommand( webhookUrl: opts.webhookUrl, audienceType: opts.audienceType, audience: opts.audience, + oauthClientId: opts.oauthClientId, + oauthClientSecret: opts.oauthClientSecret, + oauthRedirectUri: opts.oauthRedirectUri, + 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, diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts index 5fceff49e..169e70fe9 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -60,6 +60,24 @@ export type GoogleChatAccountConfig = { serviceAccount?: string | Record; /** Service account JSON file path. */ serviceAccountFile?: string; + /** OAuth client id (user auth). */ + oauthClientId?: string; + /** OAuth client secret (user auth). */ + oauthClientSecret?: string; + /** OAuth redirect URI (user auth). */ + oauthRedirectUri?: string; + /** OAuth client JSON file path (user auth). */ + oauthClientFile?: string; + /** OAuth refresh token (user auth). */ + 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). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ed7dda22a..850847d42 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -323,6 +323,15 @@ export const GoogleChatAccountSchema = z groups: z.record(z.string(), GoogleChatGroupSchema.optional()).optional(), serviceAccount: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(), serviceAccountFile: z.string().optional(), + oauthClientId: z.string().optional(), + oauthClientSecret: z.string().optional(), + oauthRedirectUri: z.string().optional(), + 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(),