feat: add gog oauth reuse for google chat
This commit is contained in:
parent
e83e4bf28c
commit
e4591ca120
@ -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. citeturn9view0turn6view0
|
||||||
|
|
||||||
|
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. citeturn6view0
|
||||||
|
- For headless systems, switch to file keyring + password (see `gog` docs). citeturn6view0
|
||||||
|
|
||||||
|
Clawdbot reads `gog` OAuth client files from:
|
||||||
|
- `~/.config/gogcli/credentials.json`
|
||||||
|
- `~/.config/gogcli/credentials-<client>.json`
|
||||||
|
- `~/.config/gogcli/credentials-<domain>.json` (or macOS equivalent) citeturn9view0
|
||||||
|
|
||||||
|
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). citeturn6view0
|
||||||
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` isn’t set.
|
- 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.
|
- 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).
|
||||||
|
|||||||
@ -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 app’s webhook auth config.
|
- `audienceType` + `audience` must match the Chat app’s 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.
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 } : {}),
|
||||||
|
|||||||
153
extensions/googlechat/src/gog.ts
Normal file
153
extensions/googlechat/src/gog.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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"),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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). */
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user