feat: add gog oauth reuse for google chat

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -72,6 +72,12 @@ export type GoogleChatAccountConfig = {
oauthRefreshToken?: string; oauthRefreshToken?: string;
/** OAuth refresh token file path (user auth). */ /** OAuth refresh token file path (user auth). */
oauthRefreshTokenFile?: string; 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). */ /** Webhook audience type (app-url or project-number). */
audienceType?: "app-url" | "project-number"; audienceType?: "app-url" | "project-number";
/** Audience value (app URL or project number). */ /** Audience value (app URL or project number). */

View File

@ -317,6 +317,9 @@ export const GoogleChatAccountSchema = z
oauthClientFile: z.string().optional(), oauthClientFile: z.string().optional(),
oauthRefreshToken: z.string().optional(), oauthRefreshToken: z.string().optional(),
oauthRefreshTokenFile: 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(), audienceType: z.enum(["app-url", "project-number"]).optional(),
audience: z.string().optional(), audience: z.string().optional(),
webhookPath: z.string().optional(), webhookPath: z.string().optional(),