feat: add gog oauth reuse for google chat
This commit is contained in:
1 parent
e83e4bf28c
commit
e4591ca120
13 files changed
+304
-13
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user