From e83e4bf28cb6738dc15c5042573f28798ad2ede0 Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 17:08:37 -0600 Subject: [PATCH 01/11] feat: add google chat user oauth support --- docs/channels/googlechat.md | 24 ++++ docs/gateway/configuration.md | 7 + extensions/googlechat/src/accounts.ts | 65 ++++++++- extensions/googlechat/src/actions.ts | 7 + extensions/googlechat/src/api.test.ts | 2 + extensions/googlechat/src/api.ts | 62 ++++++--- extensions/googlechat/src/auth.ts | 174 +++++++++++++++++++++++- extensions/googlechat/src/channel.ts | 42 +++++- extensions/googlechat/src/monitor.ts | 51 ++++++- extensions/googlechat/src/onboarding.ts | 110 ++++++++++++++- src/channels/plugins/types.core.ts | 6 + src/cli/channels-cli.ts | 12 ++ src/commands/channels/add-mutators.ts | 12 ++ src/commands/channels/add.ts | 18 +++ src/config/types.googlechat.ts | 12 ++ src/config/zod-schema.providers-core.ts | 6 + 16 files changed, 572 insertions(+), 38 deletions(-) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 00cfa7c72..b77c66ffe 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -46,6 +46,19 @@ 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 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. +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 +157,13 @@ 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 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 +191,10 @@ 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`. - 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 eaba866b1..f93d7f927 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1135,6 +1135,8 @@ 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...", audienceType: "app-url", // app-url | project-number audience: "https://gateway.example.com/googlechat", webhookPath: "/googlechat", @@ -1159,6 +1161,11 @@ 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`. - `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 366e12a51..1216ab666 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -3,7 +3,9 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.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 +13,19 @@ 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_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: ClawdbotConfig): string[] { const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts; @@ -69,13 +78,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 +116,43 @@ function resolveCredentialsFromConfig(params: { return { source: "none" }; } +function resolveUserAuthSource(params: { + accountId: string; + account: GoogleChatAccountConfig; +}): GoogleChatUserCredentialSource { + const { account, accountId } = params; + 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 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; + const hasRefresh = hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile; + if (!hasClient || !hasRefresh) return "none"; + + if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env"; + if (hasFileClient || hasFileRefresh) return "file"; + return "inline"; +} + export function resolveGoogleChatAccount(params: { cfg: ClawdbotConfig; accountId?: string | null; @@ -114,13 +164,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 f32708f55..7e77f99b9 100644 --- a/extensions/googlechat/src/actions.ts +++ b/extensions/googlechat/src/actions.ts @@ -32,6 +32,7 @@ function listEnabledAccounts(cfg: ClawdbotConfig) { function isReactionsEnabled(accounts: ReturnType, cfg: ClawdbotConfig) { 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 6ecef0a80..b15c94c81 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..915b9ecd0 100644 --- a/extensions/googlechat/src/auth.ts +++ b/extensions/googlechat/src/auth.ts @@ -1,4 +1,7 @@ +import fs from "node:fs"; + import { GoogleAuth, OAuth2Client } from "google-auth-library"; +import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; @@ -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,155 @@ 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"; + +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 readJsonFile(path: string): unknown | null { + try { + const raw = fs.readFileSync(path, "utf8"); + if (!raw.trim()) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClientConfig | null { + const cfg = account.config; + 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 (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; + if (cfg.oauthRefreshToken?.trim()) return cfg.oauthRefreshToken.trim(); + const tokenFile = cfg.oauthRefreshTokenFile?.trim(); + if (tokenFile) { + const raw = readJsonFile(tokenFile); + if (typeof raw === "string" && raw.trim()) return raw.trim(); + if (raw && typeof raw === "object") { + const record = raw as Record; + const token = + typeof record.refresh_token === "string" + ? record.refresh_token.trim() + : typeof record.refreshToken === "string" + ? record.refreshToken.trim() + : ""; + 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 raw = readJsonFile(envFile); + if (typeof raw === "string" && raw.trim()) return raw.trim(); + if (raw && typeof raw === "object") { + const record = raw as Record; + const token = + typeof record.refresh_token === "string" + ? record.refresh_token.trim() + : typeof record.refreshToken === "string" + ? record.refreshToken.trim() + : ""; + 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 +224,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 3abd3b264..3d96d905f 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -149,6 +149,12 @@ export const googlechatPlugin: ChannelPlugin = { clearBaseFields: [ "serviceAccount", "serviceAccountFile", + "oauthClientId", + "oauthClientSecret", + "oauthRedirectUri", + "oauthClientFile", + "oauthRefreshToken", + "oauthRefreshTokenFile", "audienceType", "audience", "webhookPath", @@ -298,10 +304,28 @@ 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.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) { + 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 +350,24 @@ 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 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 } : {}), ...(audienceType ? { audienceType } : {}), ...(audience ? { audience } : {}), ...(webhookPath ? { webhookPath } : {}), diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index fee138807..29c30c660 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.`, ); 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: ClawdbotConfig; 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 1b1a02371..33ae5e808 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: ClawdbotConfig, 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,83 @@ 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: "file", label: "OAuth client JSON file" }, + { value: "manual", label: "OAuth client id + secret" }, + ], + initialValue: "file", + }); + + let patch: Record = {}; + 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 = 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(), + }, + }); +} + async function promptAudience(params: { cfg: ClawdbotConfig; prompter: WizardPrompter; @@ -218,8 +305,9 @@ 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.", "Webhook verification requires audience type + audience value.", `Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`, ].join("\n"), @@ -238,7 +326,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 +353,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 6a76743f2..f1410ba94 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -35,6 +35,12 @@ export type ChannelSetupInput = { webhookUrl?: string; audienceType?: string; audience?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthRedirectUri?: string; + oauthClientFile?: string; + oauthRefreshToken?: string; + oauthRefreshTokenFile?: string; useEnv?: boolean; homeserver?: string; userId?: string; diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 97fbee520..91a33d45a 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -38,6 +38,12 @@ const optionNamesAdd = [ "webhookUrl", "audienceType", "audience", + "oauthClientId", + "oauthClientSecret", + "oauthRedirectUri", + "oauthClientFile", + "oauthRefreshToken", + "oauthRefreshTokenFile", "useEnv", "homeserver", "userId", @@ -175,6 +181,12 @@ 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("--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 a1efbf2a6..3a49de525 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -39,6 +39,12 @@ export function applyChannelAccountConfig(params: { webhookUrl?: string; audienceType?: string; audience?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthRedirectUri?: string; + oauthClientFile?: string; + oauthRefreshToken?: string; + oauthRefreshTokenFile?: string; useEnv?: boolean; homeserver?: string; userId?: string; @@ -76,6 +82,12 @@ 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, useEnv: params.useEnv, homeserver: params.homeserver, userId: params.userId, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index c1bb3aa68..f0e582840 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -36,6 +36,12 @@ export type ChannelsAddOptions = { webhookUrl?: string; audienceType?: string; audience?: string; + oauthClientId?: string; + oauthClientSecret?: string; + oauthRedirectUri?: string; + oauthClientFile?: string; + oauthRefreshToken?: string; + oauthRefreshTokenFile?: string; useEnv?: boolean; homeserver?: string; userId?: string; @@ -204,6 +210,12 @@ 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, homeserver: opts.homeserver, userId: opts.userId, accessToken: opts.accessToken, @@ -247,6 +259,12 @@ 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, 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..668a0a733 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -60,6 +60,18 @@ 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; /** 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 374e6e8aa..a2f8205b7 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -311,6 +311,12 @@ 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(), audienceType: z.enum(["app-url", "project-number"]).optional(), audience: z.string().optional(), webhookPath: z.string().optional(), From e4591ca1202c41cbc05ce46ec1e704712520727f Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 17:21:09 -0600 Subject: [PATCH 02/11] feat: add gog oauth reuse for google chat --- docs/channels/googlechat.md | 41 ++++++- docs/gateway/configuration.md | 5 + extensions/googlechat/src/accounts.ts | 17 ++- extensions/googlechat/src/auth.ts | 19 +++ extensions/googlechat/src/channel.ts | 14 ++- extensions/googlechat/src/gog.ts | 153 ++++++++++++++++++++++++ extensions/googlechat/src/onboarding.ts | 35 ++++-- src/channels/plugins/types.core.ts | 3 + src/cli/channels-cli.ts | 6 + src/commands/channels/add-mutators.ts | 6 + src/commands/channels/add.ts | 9 ++ src/config/types.googlechat.ts | 6 + src/config/zod-schema.providers-core.ts | 3 + 13 files changed, 304 insertions(+), 13 deletions(-) create mode 100644 extensions/googlechat/src/gog.ts diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index b77c66ffe..41618a521 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -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-.json` +- `~/.config/gogcli/credentials-.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` 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 f93d7f927..3f567c07f 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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 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 1216ab666..6f72509b7 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -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"; diff --git a/extensions/googlechat/src/auth.ts b/extensions/googlechat/src/auth.ts index 915b9ecd0..3c8a31519 100644 --- a/extensions/googlechat/src/auth.ts +++ b/extensions/googlechat/src/auth.ts @@ -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; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 3d96d905f..436960620 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -155,6 +155,9 @@ export const googlechatPlugin: ChannelPlugin = { "oauthClientFile", "oauthRefreshToken", "oauthRefreshTokenFile", + "oauthFromGog", + "gogAccount", + "gogClient", "audienceType", "audience", "webhookPath", @@ -308,7 +311,8 @@ export const googlechatPlugin: ChannelPlugin = { } 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 = { 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 = { 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 = { ...(oauthClientFile ? { oauthClientFile } : {}), ...(oauthRefreshToken ? { oauthRefreshToken } : {}), ...(oauthRefreshTokenFile ? { oauthRefreshTokenFile } : {}), + ...(oauthFromGog ? { oauthFromGog } : {}), + ...(gogAccount ? { gogAccount } : {}), + ...(gogClient ? { gogClient } : {}), ...(audienceType ? { audienceType } : {}), ...(audience ? { audience } : {}), ...(webhookPath ? { webhookPath } : {}), diff --git a/extensions/googlechat/src/gog.ts b/extensions/googlechat/src/gog.ts new file mode 100644 index 000000000..67851d03a --- /dev/null +++ b/extensions/googlechat/src/gog.ts @@ -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(); + +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; + 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; +} diff --git a/extensions/googlechat/src/onboarding.ts b/extensions/googlechat/src/onboarding.ts index 33ae5e808..2a5cbaf32 100644 --- a/extensions/googlechat/src/onboarding.ts +++ b/extensions/googlechat/src/onboarding.ts @@ -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 = {}; - 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"), diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index f1410ba94..4f6a69fbc 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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; diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 91a33d45a..86badefba 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -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 ", "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 3a49de525..847e5a860 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -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, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index f0e582840..b6bc95cd1 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -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, diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts index 668a0a733..169e70fe9 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -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). */ diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index a2f8205b7..c8d6d7a83 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(), From abe242a3bb51a20f7185e29dc6e51a27f861bbd4 Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 17:53:13 -0600 Subject: [PATCH 03/11] fix: clarify gog-based google chat setup --- docs/channels/googlechat.md | 6 ++++++ extensions/googlechat/src/channel.ts | 6 ++++++ extensions/googlechat/src/monitor.ts | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 41618a521..c6419bef8 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -57,6 +57,7 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + ```bash gog auth credentials /path/to/client_secret.json gog auth add you@example.com --services gmail,calendar,drive,contacts,docs,sheets + gog auth list ``` 2) Configure Google Chat to reuse `gog`: ```json5 @@ -74,6 +75,11 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + 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 +4) Verify `gog` is visible to the gateway user: + ```bash + gog auth tokens --json + ``` + If this fails, install `gog` on the gateway host and ensure the keyring is accessible. Clawdbot reads `gog` OAuth client files from: - `~/.config/gogcli/credentials.json` diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 436960620..3a45b6901 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -167,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, diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 29c30c660..7e738b928 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -648,7 +648,7 @@ async function processMessageWithPipeline(params: { let typingIndicator = account.config.typingIndicator ?? "message"; 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"; } From 3c1114d0e8d99741bf008e032a7718536efc0703 Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 18:45:09 -0600 Subject: [PATCH 04/11] fix: googlechat gog oauth token loading --- docs/channels/googlechat.md | 5 ++-- extensions/googlechat/src/channel.ts | 12 ++++++++ extensions/googlechat/src/gog.ts | 44 ++++++++++++++++++---------- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index c6419bef8..2b73e4092 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -77,16 +77,17 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + - For headless systems, switch to file keyring + password (see `gog` docs). citeturn6view0 4) Verify `gog` is visible to the gateway user: ```bash - gog auth tokens --json + gog auth tokens list --json ``` 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. Clawdbot reads `gog` OAuth client files from: - `~/.config/gogcli/credentials.json` - `~/.config/gogcli/credentials-.json` - `~/.config/gogcli/credentials-.json` (or macOS equivalent) citeturn9view0 -Clawdbot queries `gog auth tokens --json` to reuse the stored refresh token. If this fails, set `oauthRefreshToken` manually. +Clawdbot queries `gog auth tokens list --json` (and falls back to `gog auth tokens export --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 diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 3a45b6901..28c40cf81 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -543,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", @@ -584,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/gog.ts b/extensions/googlechat/src/gog.ts index 67851d03a..d2d720333 100644 --- a/extensions/googlechat/src/gog.ts +++ b/extensions/googlechat/src/gog.ts @@ -109,25 +109,37 @@ export function readGogRefreshTokenSync(params: { 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; - } + const env = { + ...process.env, + ...(params.gogAccount?.trim() ? { GOG_ACCOUNT: params.gogAccount.trim() } : {}), + ...(params.gogClient?.trim() ? { GOG_CLIENT: params.gogClient.trim() } : {}), + }; - let parsed: unknown; - try { - parsed = JSON.parse(stdout); - } catch { - return null; - } + const runGogJson = (args: string[]): unknown | null => { + try { + const stdout = execFileSync("gog", ["--no-input", ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 3000, + env, + }); + return JSON.parse(stdout); + } catch { + return null; + } + }; + const parsed = runGogJson(["auth", "tokens", "list", "--json"]); const tokens: GogTokenEntry[] = []; - collectTokens(parsed, tokens); + if (parsed) { + collectTokens(parsed, tokens); + } + if (tokens.length === 0) { + const exported = runGogJson(["auth", "tokens", "export", "--json"]); + if (exported) { + collectTokens(exported, tokens); + } + } if (tokens.length === 0) return null; const target = params.gogAccount?.trim().toLowerCase(); From 3a41e2e8bda6989a7399400588b629e52949d2a5 Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 19:11:28 -0600 Subject: [PATCH 05/11] fix: load gog refresh tokens from config keyring --- docs/channels/googlechat.md | 4 +- extensions/googlechat/src/gog.ts | 219 ++++++++++++++++++++++++++----- 2 files changed, 191 insertions(+), 32 deletions(-) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 2b73e4092..24c5f162d 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -74,7 +74,9 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + ``` 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 + - For headless systems (systemd, SSH-only), switch to file keyring + password (see `gog` docs). citeturn6view0 + - 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 the gateway user: ```bash gog auth tokens list --json diff --git a/extensions/googlechat/src/gog.ts b/extensions/googlechat/src/gog.ts index d2d720333..5d4526a9f 100644 --- a/extensions/googlechat/src/gog.ts +++ b/extensions/googlechat/src/gog.ts @@ -9,6 +9,118 @@ type GogTokenEntry = { }; const tokenCache = new Map(); +const jwtPattern = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; + +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 readJsonFile(pathname: string): unknown | null { + try { + const raw = fs.readFileSync(pathname, "utf8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +function tryParseJson(value: string): unknown | null { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function decodeBase64Payload(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.includes(".")) return null; + const normalized = trimmed.replace(/-/g, "+").replace(/_/g, "/"); + if (!/^[A-Za-z0-9+/=]+$/.test(normalized)) return null; + try { + const decoded = Buffer.from(normalized, "base64").toString("utf8"); + return decoded.trim() ? decoded : null; + } catch { + return null; + } +} + +function resolveGogKeyringFiles(params: { + gogClient?: string | null; + gogAccount?: string | null; +}): string[] { + const dirs = resolveConfigDirs().map((dir) => path.join(dir, "keyring")); + const files: string[] = []; + for (const dir of dirs) { + try { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + files.push(path.join(dir, entry.name)); + } + } catch { + // Ignore missing/permission issues; we'll fall back to other sources. + } + } + const account = params.gogAccount?.trim(); + if (account) { + const matches = files.filter((file) => file.includes(account)); + if (matches.length > 0) return matches; + } + return files; +} function resolveConfigDirs(): string[] { const dirs: string[] = []; @@ -38,40 +150,35 @@ 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[] = []; + return resolveGogJsonFile(params, "credentials"); +} - 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")); - } +function resolveGogTokenFile(params: { + gogClient?: string | null; + gogAccount?: string | null; +}): string | null { + return resolveGogJsonFile(params, "tokens"); +} - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; - } - return null; +function looksLikeJwt(token: string): boolean { + return jwtPattern.test(token.trim()); } function looksLikeRefreshToken(token: string): boolean { const trimmed = token.trim(); if (!trimmed) return false; if (trimmed.startsWith("ya29.")) return false; + if (looksLikeJwt(trimmed)) return false; if (trimmed.startsWith("1//")) return true; return trimmed.length > 30; } +function collectTokensFromString(value: string, out: GogTokenEntry[]) { + const trimmed = value.trim(); + if (!trimmed) return; + if (looksLikeRefreshToken(trimmed)) out.push({ refreshToken: trimmed }); +} + function collectTokens(value: unknown, out: GogTokenEntry[]) { if (!value || typeof value !== "object") return; if (Array.isArray(value)) { @@ -101,6 +208,36 @@ function collectTokens(value: unknown, out: GogTokenEntry[]) { } } +function collectTokensFromRaw(value: string, out: GogTokenEntry[]) { + const trimmed = value.trim(); + if (!trimmed) return; + + const parsed = tryParseJson(trimmed); + if (parsed) { + if (typeof parsed === "string") { + collectTokensFromString(parsed, out); + } else { + collectTokens(parsed, out); + } + return; + } + + const decoded = decodeBase64Payload(trimmed); + if (decoded) { + const decodedParsed = tryParseJson(decoded); + if (decodedParsed) { + if (typeof decodedParsed === "string") { + collectTokensFromString(decodedParsed, out); + } else { + collectTokens(decodedParsed, out); + } + return; + } + } + + collectTokensFromString(trimmed, out); +} + export function readGogRefreshTokenSync(params: { gogAccount?: string | null; gogClient?: string | null; @@ -109,10 +246,33 @@ export function readGogRefreshTokenSync(params: { const cached = tokenCache.get(cacheKey); if (cached) return cached; + const tokens: GogTokenEntry[] = []; + const tokenFile = resolveGogTokenFile(params); + if (tokenFile) { + const parsed = readJsonFile(tokenFile); + if (parsed) collectTokens(parsed, tokens); + } + + if (tokens.length === 0) { + const keyringFiles = resolveGogKeyringFiles(params); + for (const file of keyringFiles) { + try { + const raw = fs.readFileSync(file, "utf8"); + collectTokensFromRaw(raw, tokens); + } catch { + // Ignore keyring read errors and keep trying other entries. + } + } + } + const env = { ...process.env, - ...(params.gogAccount?.trim() ? { GOG_ACCOUNT: params.gogAccount.trim() } : {}), - ...(params.gogClient?.trim() ? { GOG_CLIENT: params.gogClient.trim() } : {}), + ...(params.gogAccount?.trim() + ? { GOG_ACCOUNT: params.gogAccount.trim() } + : {}), + ...(params.gogClient?.trim() + ? { GOG_CLIENT: params.gogClient.trim() } + : {}), }; const runGogJson = (args: string[]): unknown | null => { @@ -129,16 +289,13 @@ export function readGogRefreshTokenSync(params: { } }; - const parsed = runGogJson(["auth", "tokens", "list", "--json"]); - const tokens: GogTokenEntry[] = []; - if (parsed) { - collectTokens(parsed, tokens); + if (tokens.length === 0) { + const parsed = runGogJson(["auth", "tokens", "list", "--json"]); + if (parsed) collectTokens(parsed, tokens); } if (tokens.length === 0) { const exported = runGogJson(["auth", "tokens", "export", "--json"]); - if (exported) { - collectTokens(exported, tokens); - } + if (exported) collectTokens(exported, tokens); } if (tokens.length === 0) return null; From a7bfe30d8814c937b1902f2ddb36f81fe276e015 Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 19:28:41 -0600 Subject: [PATCH 06/11] fix: clarify gog token export flow --- docs/channels/googlechat.md | 6 +- extensions/googlechat/src/gog.ts | 204 +++++++++++-------------------- 2 files changed, 74 insertions(+), 136 deletions(-) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 24c5f162d..4b3614794 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -73,7 +73,7 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + } ``` 3) Ensure `gog` can access its keyring on the gateway host. - - `gog` stores refresh tokens in the system keychain by default. citeturn6view0 + - `gog` stores refresh tokens in the system keychain by default (not inside `credentials.json`). citeturn6view0 - For headless systems (systemd, SSH-only), switch to file keyring + password (see `gog` docs). citeturn6view0 - 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/`). @@ -81,7 +81,7 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + ```bash gog auth tokens list --json ``` - If this fails, install `gog` on the gateway host and ensure the keyring is accessible. + 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. Clawdbot reads `gog` OAuth client files from: @@ -89,7 +89,7 @@ Clawdbot reads `gog` OAuth client files from: - `~/.config/gogcli/credentials-.json` - `~/.config/gogcli/credentials-.json` (or macOS equivalent) citeturn9view0 -Clawdbot queries `gog auth tokens list --json` (and falls back to `gog auth tokens export --json`) to reuse the stored refresh token. If this fails, set `oauthRefreshToken` manually. +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). citeturn6view0 diff --git a/extensions/googlechat/src/gog.ts b/extensions/googlechat/src/gog.ts index 5d4526a9f..78ec07d56 100644 --- a/extensions/googlechat/src/gog.ts +++ b/extensions/googlechat/src/gog.ts @@ -9,7 +9,6 @@ type GogTokenEntry = { }; const tokenCache = new Map(); -const jwtPattern = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; function resolveWildcardJsonFile( dirs: string[], @@ -76,52 +75,6 @@ function readJsonFile(pathname: string): unknown | null { } } -function tryParseJson(value: string): unknown | null { - try { - return JSON.parse(value); - } catch { - return null; - } -} - -function decodeBase64Payload(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed) return null; - if (trimmed.includes(".")) return null; - const normalized = trimmed.replace(/-/g, "+").replace(/_/g, "/"); - if (!/^[A-Za-z0-9+/=]+$/.test(normalized)) return null; - try { - const decoded = Buffer.from(normalized, "base64").toString("utf8"); - return decoded.trim() ? decoded : null; - } catch { - return null; - } -} - -function resolveGogKeyringFiles(params: { - gogClient?: string | null; - gogAccount?: string | null; -}): string[] { - const dirs = resolveConfigDirs().map((dir) => path.join(dir, "keyring")); - const files: string[] = []; - for (const dir of dirs) { - try { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (!entry.isFile()) continue; - files.push(path.join(dir, entry.name)); - } - } catch { - // Ignore missing/permission issues; we'll fall back to other sources. - } - } - const account = params.gogAccount?.trim(); - if (account) { - const matches = files.filter((file) => file.includes(account)); - if (matches.length > 0) return matches; - } - return files; -} - function resolveConfigDirs(): string[] { const dirs: string[] = []; const xdg = process.env.XDG_CONFIG_HOME; @@ -153,32 +106,14 @@ export function resolveGogCredentialsFile(params: { return resolveGogJsonFile(params, "credentials"); } -function resolveGogTokenFile(params: { - gogClient?: string | null; - gogAccount?: string | null; -}): string | null { - return resolveGogJsonFile(params, "tokens"); -} - -function looksLikeJwt(token: string): boolean { - return jwtPattern.test(token.trim()); -} - function looksLikeRefreshToken(token: string): boolean { const trimmed = token.trim(); if (!trimmed) return false; if (trimmed.startsWith("ya29.")) return false; - if (looksLikeJwt(trimmed)) return false; if (trimmed.startsWith("1//")) return true; return trimmed.length > 30; } -function collectTokensFromString(value: string, out: GogTokenEntry[]) { - const trimmed = value.trim(); - if (!trimmed) return; - if (looksLikeRefreshToken(trimmed)) out.push({ refreshToken: trimmed }); -} - function collectTokens(value: unknown, out: GogTokenEntry[]) { if (!value || typeof value !== "object") return; if (Array.isArray(value)) { @@ -208,34 +143,28 @@ function collectTokens(value: unknown, out: GogTokenEntry[]) { } } -function collectTokensFromRaw(value: string, out: GogTokenEntry[]) { - const trimmed = value.trim(); - if (!trimmed) return; - - const parsed = tryParseJson(trimmed); - if (parsed) { - if (typeof parsed === "string") { - collectTokensFromString(parsed, out); - } else { - collectTokens(parsed, out); - } - return; +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); +} - const decoded = decodeBase64Payload(trimmed); - if (decoded) { - const decodedParsed = tryParseJson(decoded); - if (decodedParsed) { - if (typeof decodedParsed === "string") { - collectTokensFromString(decodedParsed, out); - } else { - collectTokens(decodedParsed, out); - } - return; - } - } - - collectTokensFromString(trimmed, out); +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: { @@ -246,25 +175,6 @@ export function readGogRefreshTokenSync(params: { const cached = tokenCache.get(cacheKey); if (cached) return cached; - const tokens: GogTokenEntry[] = []; - const tokenFile = resolveGogTokenFile(params); - if (tokenFile) { - const parsed = readJsonFile(tokenFile); - if (parsed) collectTokens(parsed, tokens); - } - - if (tokens.length === 0) { - const keyringFiles = resolveGogKeyringFiles(params); - for (const file of keyringFiles) { - try { - const raw = fs.readFileSync(file, "utf8"); - collectTokensFromRaw(raw, tokens); - } catch { - // Ignore keyring read errors and keep trying other entries. - } - } - } - const env = { ...process.env, ...(params.gogAccount?.trim() @@ -277,7 +187,7 @@ export function readGogRefreshTokenSync(params: { const runGogJson = (args: string[]): unknown | null => { try { - const stdout = execFileSync("gog", ["--no-input", ...args], { + const stdout = execFileSync("gog", ["--no-input", "--json", ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 3000, @@ -289,34 +199,62 @@ export function readGogRefreshTokenSync(params: { } }; - if (tokens.length === 0) { - const parsed = runGogJson(["auth", "tokens", "list", "--json"]); - if (parsed) collectTokens(parsed, tokens); + 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; + } } - if (tokens.length === 0) { - const exported = runGogJson(["auth", "tokens", "export", "--json"]); - if (exported) collectTokens(exported, 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, + 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, + }, ); - if (match?.refreshToken) { - tokenCache.set(cacheKey, match.refreshToken); - return match.refreshToken; + } catch { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors } + return null; } - if (tokens.length === 1) { - const only = tokens[0]?.refreshToken; - if (only) { - tokenCache.set(cacheKey, only); - return only; - } + const parsed = readJsonFile(outPath); + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors } - return null; + const tokens: GogTokenEntry[] = []; + if (parsed) collectTokens(parsed, tokens); + const token = tokens[0]?.refreshToken?.trim(); + if (!token) return null; + + tokenCache.set(cacheKey, token); + return token; } From ec4088c76ccd6ff364f255a2fc12a49afee9c259 Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 19:44:07 -0600 Subject: [PATCH 07/11] chore: refactor simplify gog token extraction --- extensions/googlechat/src/gog.ts | 63 +++++++------------------------- 1 file changed, 14 insertions(+), 49 deletions(-) diff --git a/extensions/googlechat/src/gog.ts b/extensions/googlechat/src/gog.ts index 78ec07d56..a6cbd092c 100644 --- a/extensions/googlechat/src/gog.ts +++ b/extensions/googlechat/src/gog.ts @@ -3,11 +3,6 @@ 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(); function resolveWildcardJsonFile( @@ -106,20 +101,8 @@ export function resolveGogCredentialsFile(params: { return resolveGogJsonFile(params, "credentials"); } -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; - } +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" @@ -127,20 +110,11 @@ function collectTokens(value: unknown, out: GogTokenEntry[]) { : 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); - } + 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[] { @@ -234,27 +208,18 @@ export function readGogRefreshTokenSync(params: { 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 } - return null; } - - const parsed = readJsonFile(outPath); - try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors - } - - const tokens: GogTokenEntry[] = []; - if (parsed) collectTokens(parsed, tokens); - const token = tokens[0]?.refreshToken?.trim(); - if (!token) return null; - - tokenCache.set(cacheKey, token); - return token; } From 66c10e9562bbd891d84c89b51b10e4be40a555c8 Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 20:16:03 -0600 Subject: [PATCH 08/11] fix: googlechat auth token sourcing --- docs/channels/googlechat.md | 10 +++--- extensions/googlechat/src/accounts.ts | 2 +- extensions/googlechat/src/auth.ts | 44 +++++------------------ extensions/googlechat/src/file-utils.ts | 48 +++++++++++++++++++++++++ extensions/googlechat/src/gog.ts | 11 ++---- 5 files changed, 64 insertions(+), 51 deletions(-) create mode 100644 extensions/googlechat/src/file-utils.ts diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 4b3614794..5a35a521e 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -51,7 +51,7 @@ Service accounts cover most bot workflows, but **reactions and user-attributed a ### 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 +`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 @@ -73,8 +73,8 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + } ``` 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`). citeturn6view0 - - For headless systems (systemd, SSH-only), switch to file keyring + password (see `gog` docs). citeturn6view0 + - `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 the gateway user: @@ -87,12 +87,12 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + Clawdbot reads `gog` OAuth client files from: - `~/.config/gogcli/credentials.json` - `~/.config/gogcli/credentials-.json` -- `~/.config/gogcli/credentials-.json` (or macOS equivalent) citeturn9view0 +- `~/.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). citeturn6view0 +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` diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index 6f72509b7..8895a178b 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -161,8 +161,8 @@ function resolveUserAuthSource(params: { hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile || hasGogRefresh; if (!hasClient || !hasRefresh) return "none"; - if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env"; if (hasFileClient || hasFileRefresh) return "file"; + if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env"; return "inline"; } diff --git a/extensions/googlechat/src/auth.ts b/extensions/googlechat/src/auth.ts index 3c8a31519..64be9f723 100644 --- a/extensions/googlechat/src/auth.ts +++ b/extensions/googlechat/src/auth.ts @@ -1,9 +1,8 @@ -import fs from "node:fs"; - 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"; @@ -93,16 +92,6 @@ function parseOAuthClientJson(raw: unknown): OAuthClientConfig | null { return { clientId, clientSecret, redirectUri: redirect || undefined }; } -function readJsonFile(path: string): unknown | null { - try { - const raw = fs.readFileSync(path, "utf8"); - if (!raw.trim()) return null; - return JSON.parse(raw); - } catch { - return null; - } -} - function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClientConfig | null { const cfg = account.config; const gogAccount = cfg.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined; @@ -154,42 +143,25 @@ function resolveOAuthRefreshToken(account: ResolvedGoogleChatAccount): string | 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 raw = readJsonFile(tokenFile); - if (typeof raw === "string" && raw.trim()) return raw.trim(); - if (raw && typeof raw === "object") { - const record = raw as Record; - const token = - typeof record.refresh_token === "string" - ? record.refresh_token.trim() - : typeof record.refreshToken === "string" - ? record.refreshToken.trim() - : ""; - if (token) return token; - } + 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 raw = readJsonFile(envFile); - if (typeof raw === "string" && raw.trim()) return raw.trim(); - if (raw && typeof raw === "object") { - const record = raw as Record; - const token = - typeof record.refresh_token === "string" - ? record.refresh_token.trim() - : typeof record.refreshToken === "string" - ? record.refreshToken.trim() - : ""; - if (token) return token; - } + const token = readRefreshTokenFromFile(envFile); + if (token) return token; } } return null; diff --git a/extensions/googlechat/src/file-utils.ts b/extensions/googlechat/src/file-utils.ts new file mode 100644 index 000000000..8219b4771 --- /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}: ${err.message}`); + } + 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 index a6cbd092c..b7add3b75 100644 --- a/extensions/googlechat/src/gog.ts +++ b/extensions/googlechat/src/gog.ts @@ -3,6 +3,8 @@ 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( @@ -61,15 +63,6 @@ function resolveGogJsonFile( return resolveWildcardJsonFile(dirs, baseName); } -function readJsonFile(pathname: string): unknown | null { - try { - const raw = fs.readFileSync(pathname, "utf8"); - return JSON.parse(raw); - } catch { - return null; - } -} - function resolveConfigDirs(): string[] { const dirs: string[] = []; const xdg = process.env.XDG_CONFIG_HOME; From 9a33c15743e775dd82d8caf511397b8a1d8690ec Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 20:16:30 -0600 Subject: [PATCH 09/11] docs: clarify google chat oauth setup --- docs/channels/googlechat.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 5a35a521e..b97f5e975 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. @@ -47,7 +45,7 @@ Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only). 9) Start the gateway. Google Chat will POST to your webhook path. ## User OAuth (optional, enables reactions) -Service accounts cover most bot workflows, but **reactions and user-attributed actions require user OAuth**. +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. @@ -55,8 +53,6 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + 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 gog auth list ``` 2) Configure Google Chat to reuse `gog`: @@ -77,12 +73,23 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + - 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 the gateway user: +4) Verify `gog` is visible to clawdbot and ask it to run: ```bash - gog auth tokens list --json + 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: "message", + } + } +} +``` Clawdbot reads `gog` OAuth client files from: - `~/.config/gogcli/credentials.json` From 3c00f9b73b474f827a1073bea2dc738f1b56cbbe Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 20:28:26 -0600 Subject: [PATCH 10/11] fix: align googlechat reaction handling and docs typo --- docs/channels/googlechat.md | 2 +- extensions/googlechat/src/file-utils.ts | 2 +- extensions/googlechat/src/gog.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index b97f5e975..f7ccaccd6 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -85,7 +85,7 @@ If you already use `gog` for Google Workspace, you can reuse its OAuth client + channels: { "googlechat": { actions: { reactions: true }, - typingIndicator: "message", + typingIndicator: "reaction", } } } diff --git a/extensions/googlechat/src/file-utils.ts b/extensions/googlechat/src/file-utils.ts index 8219b4771..1c0c898b5 100644 --- a/extensions/googlechat/src/file-utils.ts +++ b/extensions/googlechat/src/file-utils.ts @@ -12,7 +12,7 @@ export function readJsonFile(filePath: string): unknown | null { } 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}: ${err.message}`); + console.error(`Failed to read or parse JSON file ${filePath}`); } return null; } diff --git a/extensions/googlechat/src/gog.ts b/extensions/googlechat/src/gog.ts index b7add3b75..994a55b56 100644 --- a/extensions/googlechat/src/gog.ts +++ b/extensions/googlechat/src/gog.ts @@ -189,6 +189,7 @@ export function readGogRefreshTokenSync(params: { "auth", "tokens", "export", + "--", account, "--out", outPath, From e37f6730123d9120457beaf7e74aed998cf4f4ab Mon Sep 17 00:00:00 2001 From: iHildy Date: Mon, 26 Jan 2026 20:37:30 -0600 Subject: [PATCH 11/11] fix: removed the -- sentinel breaking parsing --- extensions/googlechat/src/gog.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/googlechat/src/gog.ts b/extensions/googlechat/src/gog.ts index 994a55b56..b7add3b75 100644 --- a/extensions/googlechat/src/gog.ts +++ b/extensions/googlechat/src/gog.ts @@ -189,7 +189,6 @@ export function readGogRefreshTokenSync(params: { "auth", "tokens", "export", - "--", account, "--out", outPath,