feat: add gog oauth reuse for google chat

This commit is contained in:
iHildy committed 2026-01-26 17:21:09 -06:00
1 parent e83e4bf28c
commit e4591ca120
13 files changed
+304 -13

No files matched your search

+15 -2
View File
@@ -2,6 +2,7 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.js";
import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js";
export type GoogleChatAppCredentialSource = "file" | "inline" | "env" | "none";
export type GoogleChatUserCredentialSource = "file" | "inline" | "env" | "none";
@@ -21,6 +22,8 @@ export type ResolvedGoogleChatAccount = {
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const ENV_GOG_ACCOUNT = "GOG_ACCOUNT";
const ENV_GOG_CLIENT = "GOG_CLIENT";
const ENV_OAUTH_CLIENT_ID = "GOOGLE_CHAT_OAUTH_CLIENT_ID";
const ENV_OAUTH_CLIENT_SECRET = "GOOGLE_CHAT_OAUTH_CLIENT_SECRET";
const ENV_OAUTH_CLIENT_FILE = "GOOGLE_CHAT_OAUTH_CLIENT_FILE";
@@ -121,6 +124,8 @@ function resolveUserAuthSource(params: {
account: GoogleChatAccountConfig;
}): GoogleChatUserCredentialSource {
const { account, accountId } = params;
const gogAccount = account.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined;
const gogClient = account.gogClient?.trim() || process.env[ENV_GOG_CLIENT]?.trim() || undefined;
const clientId = account.oauthClientId?.trim();
const clientSecret = account.oauthClientSecret?.trim();
const clientFile = account.oauthClientFile?.trim();
@@ -131,6 +136,12 @@ function resolveUserAuthSource(params: {
const hasFileClient = hasNonEmptyString(clientFile);
const hasInlineRefresh = hasNonEmptyString(refreshToken);
const hasFileRefresh = hasNonEmptyString(refreshTokenFile);
const hasGogClient = account.oauthFromGog
? Boolean(resolveGogCredentialsFile({ gogClient, gogAccount }))
: false;
const hasGogRefresh = account.oauthFromGog
? Boolean(readGogRefreshTokenSync({ gogAccount, gogClient }))
: false;
const hasEnvClient =
accountId === DEFAULT_ACCOUNT_ID &&
@@ -144,8 +155,10 @@ function resolveUserAuthSource(params: {
accountId === DEFAULT_ACCOUNT_ID &&
hasNonEmptyString(process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]);
const hasClient = hasInlineClient || hasFileClient || hasEnvClient || hasEnvClientFile;
const hasRefresh = hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile;
const hasClient =
hasInlineClient || hasFileClient || hasEnvClient || hasEnvClientFile || hasGogClient;
const hasRefresh =
hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile || hasGogRefresh;
if (!hasClient || !hasRefresh) return "none";
if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env";
+19
View File
@@ -4,6 +4,7 @@ import { GoogleAuth, OAuth2Client } from "google-auth-library";
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js";
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
const CHAT_ISSUER = "chat@system.gserviceaccount.com";
@@ -65,6 +66,8 @@ const ENV_OAUTH_REDIRECT_URI = "GOOGLE_CHAT_OAUTH_REDIRECT_URI";
const ENV_OAUTH_CLIENT_FILE = "GOOGLE_CHAT_OAUTH_CLIENT_FILE";
const ENV_OAUTH_REFRESH_TOKEN = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN";
const ENV_OAUTH_REFRESH_TOKEN_FILE = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN_FILE";
const ENV_GOG_ACCOUNT = "GOG_ACCOUNT";
const ENV_GOG_CLIENT = "GOG_CLIENT";
type OAuthClientConfig = {
clientId: string;
@@ -102,6 +105,8 @@ function readJsonFile(path: string): unknown | null {
function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClientConfig | null {
const cfg = account.config;
const gogAccount = cfg.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined;
const gogClient = cfg.gogClient?.trim() || process.env[ENV_GOG_CLIENT]?.trim() || undefined;
const inlineId = cfg.oauthClientId?.trim();
const inlineSecret = cfg.oauthClientSecret?.trim();
const inlineRedirect = cfg.oauthRedirectUri?.trim();
@@ -119,6 +124,14 @@ function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClie
if (parsed) return parsed;
}
if (cfg.oauthFromGog) {
const gogCredentials = resolveGogCredentialsFile({ gogClient, gogAccount });
if (gogCredentials) {
const parsed = parseOAuthClientJson(readJsonFile(gogCredentials));
if (parsed) return parsed;
}
}
if (account.accountId === DEFAULT_ACCOUNT_ID) {
const envId = process.env[ENV_OAUTH_CLIENT_ID]?.trim();
const envSecret = process.env[ENV_OAUTH_CLIENT_SECRET]?.trim();
@@ -138,6 +151,8 @@ function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClie
function resolveOAuthRefreshToken(account: ResolvedGoogleChatAccount): string | null {
const cfg = account.config;
const gogAccount = cfg.gogAccount?.trim() || process.env[ENV_GOG_ACCOUNT]?.trim() || undefined;
const gogClient = cfg.gogClient?.trim() || process.env[ENV_GOG_CLIENT]?.trim() || undefined;
if (cfg.oauthRefreshToken?.trim()) return cfg.oauthRefreshToken.trim();
const tokenFile = cfg.oauthRefreshTokenFile?.trim();
if (tokenFile) {
@@ -154,6 +169,10 @@ function resolveOAuthRefreshToken(account: ResolvedGoogleChatAccount): string |
if (token) return token;
}
}
if (cfg.oauthFromGog) {
const token = readGogRefreshTokenSync({ gogAccount, gogClient });
if (token) return token;
}
if (account.accountId === DEFAULT_ACCOUNT_ID) {
const envToken = process.env[ENV_OAUTH_REFRESH_TOKEN]?.trim();
if (envToken) return envToken;
+12 -2
View File
@@ -155,6 +155,9 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
"oauthClientFile",
"oauthRefreshToken",
"oauthRefreshTokenFile",
"oauthFromGog",
"gogAccount",
"gogClient",
"audienceType",
"audience",
"webhookPath",
@@ -308,7 +311,8 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
}
const hasServiceAccount = Boolean(input.token || input.tokenFile);
const hasOauthInput = Boolean(
input.oauthClientId ||
input.oauthFromGog ||
input.oauthClientId ||
input.oauthClientSecret ||
input.oauthRedirectUri ||
input.oauthClientFile ||
@@ -318,7 +322,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
if (!input.useEnv && !hasServiceAccount && !hasOauthInput) {
return "Google Chat requires service account JSON or OAuth credentials.";
}
if (hasOauthInput) {
if (hasOauthInput && !input.oauthFromGog) {
const hasClient =
Boolean(input.oauthClientFile) ||
(Boolean(input.oauthClientId) && Boolean(input.oauthClientSecret));
@@ -356,6 +360,9 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
const oauthClientFile = input.oauthClientFile?.trim();
const oauthRefreshToken = input.oauthRefreshToken?.trim();
const oauthRefreshTokenFile = input.oauthRefreshTokenFile?.trim();
const oauthFromGog = input.oauthFromGog === true ? true : undefined;
const gogAccount = input.gogAccount?.trim();
const gogClient = input.gogClient?.trim();
const audienceType = input.audienceType?.trim();
const audience = input.audience?.trim();
const webhookPath = input.webhookPath?.trim();
@@ -368,6 +375,9 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
...(oauthClientFile ? { oauthClientFile } : {}),
...(oauthRefreshToken ? { oauthRefreshToken } : {}),
...(oauthRefreshTokenFile ? { oauthRefreshTokenFile } : {}),
...(oauthFromGog ? { oauthFromGog } : {}),
...(gogAccount ? { gogAccount } : {}),
...(gogClient ? { gogClient } : {}),
...(audienceType ? { audienceType } : {}),
...(audience ? { audience } : {}),
...(webhookPath ? { webhookPath } : {}),
+153
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;
}
+27 -8
View File
@@ -218,14 +218,29 @@ async function promptOAuthCredentials(params: {
const method = await prompter.select({
message: "OAuth client source",
options: [
{ value: "gog", label: "Reuse gog OAuth (recommended if already set up)" },
{ value: "file", label: "OAuth client JSON file" },
{ value: "manual", label: "OAuth client id + secret" },
],
initialValue: "file",
initialValue: "gog",
});
let patch: Record<string, unknown> = {};
if (method === "file") {
if (method === "gog") {
const gogAccount = await prompter.text({
message: "gog account email (optional)",
placeholder: "you@example.com",
});
const gogClient = await prompter.text({
message: "gog client name (optional)",
placeholder: "work",
});
patch = {
oauthFromGog: true,
...(String(gogAccount ?? "").trim() ? { gogAccount: String(gogAccount).trim() } : {}),
...(String(gogClient ?? "").trim() ? { gogClient: String(gogClient).trim() } : {}),
};
} else if (method === "file") {
const path = await prompter.text({
message: "OAuth client JSON path",
placeholder: "/path/to/oauth-client.json",
@@ -254,18 +269,21 @@ async function promptOAuthCredentials(params: {
};
}
const refreshToken = await prompter.text({
message: "OAuth refresh token",
placeholder: "1//0g...",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const refreshToken =
method === "gog"
? undefined
: await prompter.text({
message: "OAuth refresh token",
placeholder: "1//0g...",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
return applyAccountConfig({
cfg,
accountId,
patch: {
...patch,
oauthRefreshToken: String(refreshToken).trim(),
...(refreshToken ? { oauthRefreshToken: String(refreshToken).trim() } : {}),
},
});
}
@@ -308,6 +326,7 @@ async function noteGoogleChatSetup(prompter: WizardPrompter) {
"Google Chat apps use service-account auth or user OAuth plus an HTTPS webhook.",
"Set the Chat API scopes in your service account and configure the Chat app URL.",
"User OAuth enables reactions and other user-level APIs.",
"If gog is configured, you can reuse its OAuth credentials for Chat.",
"Webhook verification requires audience type + audience value.",
`Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`,
].join("\n"),