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;