This commit is contained in:
Ian Hildebrand 2026-01-30 10:45:22 -06:00 committed by GitHub
commit a92a4f6c49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 986 additions and 42 deletions

View File

@ -5,9 +5,7 @@ read_when:
---
# Google Chat (Chat API)
Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
## Quick setup (beginner)
## Service Account Setup
1) Create a Google Cloud project and enable the **Google Chat API**.
- Go to: [Google Chat API Credentials](https://console.cloud.google.com/apis/api/chat.googleapis.com/credentials)
- Enable the API if it is not already enabled.
@ -46,6 +44,71 @@ Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
8) Set the webhook audience type + value (matches your Chat app config).
9) Start the gateway. Google Chat will POST to your webhook path.
## User OAuth (optional, enables reactions)
Service account covers almost all features, but **reactions and user-attributed actions require user OAuth**.
### 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.
1) Ensure `gog` is already authorized:
```bash
gog auth list
```
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 (not inside `credentials.json`).
- For headless systems (systemd, SSH-only), switch to file keyring + password (see `gog` docs).
- Set `GOG_KEYRING_BACKEND=file` and `GOG_KEYRING_PASSWORD=...` for the gateway service.
- The file keyring lives under your gog config directory (for example `~/.config/gogcli/keyring/`).
4) Verify `gog` is visible to clawdbot and ask it to run:
```bash
Run `gog auth tokens list --json` and tell me if you can access services.
```
This lists token keys only (no secrets). If this fails, install `gog` on the gateway host and ensure the keyring is accessible.
For non-interactive services, set `GOG_KEYRING_PASSWORD` in the gateway environment so `gog` can unlock the keyring.
5) Set `typingIndicator` to "reaction" in your clawdbot config.
```json5
{
channels: {
"googlechat": {
actions: { reactions: true },
typingIndicator: "reaction",
}
}
}
```
Clawdbot reads `gog` OAuth client files from:
- `~/.config/gogcli/credentials.json`
- `~/.config/gogcli/credentials-<client>.json`
- `~/.config/gogcli/credentials-<domain>.json` (or macOS equivalent)
Clawdbot queries `gog auth tokens list --json` to discover which account to use, then runs `gog auth tokens export <email> --out <tmp>` to read the refresh token. If you have multiple gog accounts, set `gogAccount` (or `GOG_ACCOUNT`) to pick the right one. If this fails, set `oauthRefreshToken` manually.
### Option B: Manual OAuth
1) Configure OAuth consent + create OAuth client credentials in your Google Cloud project (desktop app recommended).
2) Use an OAuth 2.0 flow to request **offline** access and collect a refresh token.
- Required scopes for reactions include:
- `https://www.googleapis.com/auth/chat.messages.reactions.create`
- `https://www.googleapis.com/auth/chat.messages.reactions`
- (or) `https://www.googleapis.com/auth/chat.messages`
3) Save the client credentials + refresh token in your config or env vars (examples below).
**Tip:** user OAuth actions are attributed to the user in Google Chat.
## Add to Google Chat
Once the gateway is running and your email is added to the visibility list:
1) Go to [Google Chat](https://chat.google.com/).
@ -144,6 +207,17 @@ Use these identifiers for delivery and allowlists:
"googlechat": {
enabled: true,
serviceAccountFile: "/path/to/service-account.json",
// Optional: user OAuth for reactions + user-attributed actions
oauthClientFile: "/path/to/oauth-client.json",
oauthRefreshToken: "1//0g...",
// Or reuse gog:
// oauthFromGog: true,
// gogAccount: "you@example.com",
// gogClient: "work",
// Or explicit fields:
// oauthClientId: "123456.apps.googleusercontent.com",
// oauthClientSecret: "GOCSPX-...",
// oauthRedirectUri: "https://your.host/googlechat/oauth/callback",
audienceType: "app-url",
audience: "https://gateway.example.com/googlechat",
webhookPath: "/googlechat",
@ -171,6 +245,11 @@ Use these identifiers for delivery and allowlists:
Notes:
- Service account credentials can also be passed inline with `serviceAccount` (JSON string).
- User OAuth can be provided via `oauthClientFile` + `oauthRefreshToken` or the explicit client fields.
- 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_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.
- 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).

View File

@ -1140,6 +1140,12 @@ Multi-account support lives under `channels.googlechat.accounts` (see the multi-
"googlechat": {
enabled: true,
serviceAccountFile: "/path/to/service-account.json",
oauthClientFile: "/path/to/oauth-client.json",
oauthRefreshToken: "1//0g...",
// Or reuse gog OAuth:
// oauthFromGog: true,
// gogAccount: "you@example.com",
// gogClient: "work",
audienceType: "app-url", // app-url | project-number
audience: "https://gateway.example.com/googlechat",
webhookPath: "/googlechat",
@ -1164,6 +1170,12 @@ Multi-account support lives under `channels.googlechat.accounts` (see the multi-
Notes:
- Service account JSON can be inline (`serviceAccount`) or file-based (`serviceAccountFile`).
- Env fallbacks for the default account: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`.
- User OAuth can be provided via `oauthClientFile` + `oauthRefreshToken` (or explicit client fields).
- Env fallbacks for user OAuth (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_REFRESH_TOKEN`,
`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.
- Use `spaces/<spaceId>` or `users/<userId|email>` when setting delivery targets.

View File

@ -2,8 +2,11 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.js";
import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js";
export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
export type GoogleChatAppCredentialSource = "file" | "inline" | "env" | "none";
export type GoogleChatUserCredentialSource = "file" | "inline" | "env" | "none";
export type GoogleChatCredentialSource = "file" | "inline" | "env" | "oauth" | "none";
export type ResolvedGoogleChatAccount = {
accountId: string;
@ -11,12 +14,21 @@ export type ResolvedGoogleChatAccount = {
enabled: boolean;
config: GoogleChatAccountConfig;
credentialSource: GoogleChatCredentialSource;
appCredentialSource: GoogleChatAppCredentialSource;
userCredentialSource: GoogleChatUserCredentialSource;
credentials?: Record<string, unknown>;
credentialsFile?: string;
};
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";
const ENV_OAUTH_REFRESH_TOKEN = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN";
const ENV_OAUTH_REFRESH_TOKEN_FILE = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN_FILE";
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts;
@ -69,13 +81,17 @@ function parseServiceAccount(value: unknown): Record<string, unknown> | null {
}
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function resolveCredentialsFromConfig(params: {
accountId: string;
account: GoogleChatAccountConfig;
}): {
credentials?: Record<string, unknown>;
credentialsFile?: string;
source: GoogleChatCredentialSource;
source: GoogleChatAppCredentialSource;
} {
const { account, accountId } = params;
const inline = parseServiceAccount(account.serviceAccount);
@ -103,6 +119,53 @@ function resolveCredentialsFromConfig(params: {
return { source: "none" };
}
function resolveUserAuthSource(params: {
accountId: string;
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();
const refreshToken = account.oauthRefreshToken?.trim();
const refreshTokenFile = account.oauthRefreshTokenFile?.trim();
const hasInlineClient = hasNonEmptyString(clientId) && hasNonEmptyString(clientSecret);
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 &&
hasNonEmptyString(process.env[ENV_OAUTH_CLIENT_ID]) &&
hasNonEmptyString(process.env[ENV_OAUTH_CLIENT_SECRET]);
const hasEnvClientFile =
accountId === DEFAULT_ACCOUNT_ID && hasNonEmptyString(process.env[ENV_OAUTH_CLIENT_FILE]);
const hasEnvRefresh =
accountId === DEFAULT_ACCOUNT_ID && hasNonEmptyString(process.env[ENV_OAUTH_REFRESH_TOKEN]);
const hasEnvRefreshFile =
accountId === DEFAULT_ACCOUNT_ID &&
hasNonEmptyString(process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]);
const hasClient =
hasInlineClient || hasFileClient || hasEnvClient || hasEnvClientFile || hasGogClient;
const hasRefresh =
hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile || hasGogRefresh;
if (!hasClient || !hasRefresh) return "none";
if (hasFileClient || hasFileRefresh) return "file";
if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env";
return "inline";
}
export function resolveGoogleChatAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@ -114,13 +177,22 @@ export function resolveGoogleChatAccount(params: {
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const credentials = resolveCredentialsFromConfig({ accountId, account: merged });
const userCredentialSource = resolveUserAuthSource({ accountId, account: merged });
const credentialSource =
credentials.source !== "none"
? credentials.source
: userCredentialSource !== "none"
? "oauth"
: "none";
return {
accountId,
name: merged.name?.trim() || undefined,
enabled,
config: merged,
credentialSource: credentials.source,
credentialSource,
appCredentialSource: credentials.source,
userCredentialSource,
credentials: credentials.credentials,
credentialsFile: credentials.credentialsFile,
};

View File

@ -32,6 +32,7 @@ function listEnabledAccounts(cfg: OpenClawConfig) {
function isReactionsEnabled(accounts: ReturnType<typeof listEnabledAccounts>, cfg: OpenClawConfig) {
for (const account of accounts) {
if (account.userCredentialSource === "none") continue;
const gate = createActionGate(
(account.config.actions ?? (cfg.channels?.["googlechat"] as { actions?: unknown })?.actions) as Record<
string,
@ -119,6 +120,9 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
}
if (action === "react") {
if (account.userCredentialSource === "none") {
throw new Error("Google Chat reactions require user OAuth credentials.");
}
const messageName = readStringParam(params, "messageId", { required: true });
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Google Chat reaction.",
@ -147,6 +151,9 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
}
if (action === "reactions") {
if (account.userCredentialSource === "none") {
throw new Error("Google Chat reactions require user OAuth credentials.");
}
const messageName = readStringParam(params, "messageId", { required: true });
const limit = readNumberParam(params, "limit", { integer: true });
const reactions = await listGoogleChatReactions({

View File

@ -11,6 +11,8 @@ const account = {
accountId: "default",
enabled: true,
credentialSource: "inline",
appCredentialSource: "inline",
userCredentialSource: "none",
config: {},
} as ResolvedGoogleChatAccount;

View File

@ -1,7 +1,7 @@
import crypto from "node:crypto";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { getGoogleChatAccessToken } from "./auth.js";
import { getGoogleChatAccessToken, type GoogleChatAuthMode } from "./auth.js";
import type { GoogleChatReaction } from "./types.js";
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
@ -11,8 +11,9 @@ async function fetchJson<T>(
account: ResolvedGoogleChatAccount,
url: string,
init: RequestInit,
options?: { authMode?: GoogleChatAuthMode },
): Promise<T> {
const token = await getGoogleChatAccessToken(account);
const token = await getGoogleChatAccessToken(account, { mode: options?.authMode });
const res = await fetch(url, {
...init,
headers: {
@ -32,8 +33,9 @@ async function fetchOk(
account: ResolvedGoogleChatAccount,
url: string,
init: RequestInit,
options?: { authMode?: GoogleChatAuthMode },
): Promise<void> {
const token = await getGoogleChatAccessToken(account);
const token = await getGoogleChatAccessToken(account, { mode: options?.authMode });
const res = await fetch(url, {
...init,
headers: {
@ -51,9 +53,9 @@ async function fetchBuffer(
account: ResolvedGoogleChatAccount,
url: string,
init?: RequestInit,
options?: { maxBytes?: number },
options?: { maxBytes?: number; authMode?: GoogleChatAuthMode },
): Promise<{ buffer: Buffer; contentType?: string }> {
const token = await getGoogleChatAccessToken(account);
const token = await getGoogleChatAccessToken(account, { mode: options?.authMode });
const res = await fetch(url, {
...init,
headers: {
@ -115,10 +117,14 @@ export async function sendGoogleChatMessage(params: {
}));
}
const url = `${CHAT_API_BASE}/${space}/messages`;
const result = await fetchJson<{ name?: string }>(account, url, {
method: "POST",
body: JSON.stringify(body),
});
const result = await fetchJson<{ name?: string }>(
account,
url,
{
method: "POST",
body: JSON.stringify(body),
},
);
return result ? { messageName: result.name } : null;
}
@ -129,10 +135,14 @@ export async function updateGoogleChatMessage(params: {
}): Promise<{ messageName?: string }> {
const { account, messageName, text } = params;
const url = `${CHAT_API_BASE}/${messageName}?updateMask=text`;
const result = await fetchJson<{ name?: string }>(account, url, {
method: "PATCH",
body: JSON.stringify({ text }),
});
const result = await fetchJson<{ name?: string }>(
account,
url,
{
method: "PATCH",
body: JSON.stringify({ text }),
},
);
return { messageName: result.name };
}
@ -202,10 +212,15 @@ export async function createGoogleChatReaction(params: {
}): Promise<GoogleChatReaction> {
const { account, messageName, emoji } = params;
const url = `${CHAT_API_BASE}/${messageName}/reactions`;
return await fetchJson<GoogleChatReaction>(account, url, {
method: "POST",
body: JSON.stringify({ emoji: { unicode: emoji } }),
});
return await fetchJson<GoogleChatReaction>(
account,
url,
{
method: "POST",
body: JSON.stringify({ emoji: { unicode: emoji } }),
},
{ authMode: "user" },
);
}
export async function listGoogleChatReactions(params: {
@ -216,9 +231,14 @@ export async function listGoogleChatReactions(params: {
const { account, messageName, limit } = params;
const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`);
if (limit && limit > 0) url.searchParams.set("pageSize", String(limit));
const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), {
method: "GET",
});
const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(
account,
url.toString(),
{
method: "GET",
},
{ authMode: "user" },
);
return result.reactions ?? [];
}
@ -228,7 +248,7 @@ export async function deleteGoogleChatReaction(params: {
}): Promise<void> {
const { account, reactionName } = params;
const url = `${CHAT_API_BASE}/${reactionName}`;
await fetchOk(account, url, { method: "DELETE" });
await fetchOk(account, url, { method: "DELETE" }, { authMode: "user" });
}
export async function findGoogleChatDirectMessage(params: {

View File

@ -1,6 +1,9 @@
import { GoogleAuth, OAuth2Client } from "google-auth-library";
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { readJsonFile, readRefreshTokenFromFile } from "./file-utils.js";
import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js";
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
const CHAT_ISSUER = "chat@system.gserviceaccount.com";
@ -10,6 +13,7 @@ const CHAT_CERTS_URL =
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
const authCache = new Map<string, { key: string; auth: GoogleAuth }>();
const oauthCache = new Map<string, { key: string; client: OAuth2Client }>();
const verifyClient = new OAuth2Client();
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
@ -42,7 +46,7 @@ function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
return auth;
}
export async function getGoogleChatAccessToken(
export async function getGoogleChatAppAccessToken(
account: ResolvedGoogleChatAccount,
): Promise<string> {
const auth = getAuthInstance(account);
@ -55,6 +59,146 @@ export async function getGoogleChatAccessToken(
return token;
}
const ENV_OAUTH_CLIENT_ID = "GOOGLE_CHAT_OAUTH_CLIENT_ID";
const ENV_OAUTH_CLIENT_SECRET = "GOOGLE_CHAT_OAUTH_CLIENT_SECRET";
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;
clientSecret: string;
redirectUri?: string;
};
function parseOAuthClientJson(raw: unknown): OAuthClientConfig | null {
if (!raw || typeof raw !== "object") return null;
const record = raw as Record<string, unknown>;
const container =
(record.web as Record<string, unknown> | undefined) ??
(record.installed as Record<string, unknown> | undefined) ??
record;
const clientId = typeof container.client_id === "string" ? container.client_id.trim() : "";
const clientSecret =
typeof container.client_secret === "string" ? container.client_secret.trim() : "";
const redirect =
Array.isArray(container.redirect_uris) && typeof container.redirect_uris[0] === "string"
? container.redirect_uris[0].trim()
: "";
if (!clientId || !clientSecret) return null;
return { clientId, clientSecret, redirectUri: redirect || undefined };
}
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();
if (inlineId && inlineSecret) {
return {
clientId: inlineId,
clientSecret: inlineSecret,
redirectUri: inlineRedirect || undefined,
};
}
const filePath = cfg.oauthClientFile?.trim();
if (filePath) {
const parsed = parseOAuthClientJson(readJsonFile(filePath));
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();
const envRedirect = process.env[ENV_OAUTH_REDIRECT_URI]?.trim();
if (envId && envSecret) {
return { clientId: envId, clientSecret: envSecret, redirectUri: envRedirect || undefined };
}
const envFile = process.env[ENV_OAUTH_CLIENT_FILE]?.trim();
if (envFile) {
const parsed = parseOAuthClientJson(readJsonFile(envFile));
if (parsed) return parsed;
}
}
return null;
}
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) {
const token = readRefreshTokenFromFile(tokenFile);
if (token) return token;
}
if (cfg.oauthFromGog) {
const token = readGogRefreshTokenSync({ gogAccount, gogClient });
if (token) return token;
}
if (account.accountId === DEFAULT_ACCOUNT_ID) {
const envToken = process.env[ENV_OAUTH_REFRESH_TOKEN]?.trim();
if (envToken) return envToken;
const envFile = process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]?.trim();
if (envFile) {
const token = readRefreshTokenFromFile(envFile);
if (token) return token;
}
}
return null;
}
function getOAuthClient(account: ResolvedGoogleChatAccount): OAuth2Client {
const clientConfig = resolveOAuthClientConfig(account);
const refreshToken = resolveOAuthRefreshToken(account);
if (!clientConfig || !refreshToken) {
throw new Error("Missing Google Chat OAuth client credentials or refresh token");
}
const key = `${clientConfig.clientId}:${clientConfig.clientSecret}:${clientConfig.redirectUri ?? ""}:${refreshToken}`;
const cached = oauthCache.get(account.accountId);
if (cached && cached.key === key) return cached.client;
const client = new OAuth2Client(
clientConfig.clientId,
clientConfig.clientSecret,
clientConfig.redirectUri,
);
client.setCredentials({ refresh_token: refreshToken });
oauthCache.set(account.accountId, { key, client });
return client;
}
export async function getGoogleChatUserAccessToken(
account: ResolvedGoogleChatAccount,
): Promise<string> {
const client = getOAuthClient(account);
const access = await client.getAccessToken();
const token = typeof access === "string" ? access : access?.token;
if (!token) {
throw new Error("Missing Google Chat OAuth access token");
}
return token;
}
async function fetchChatCerts(): Promise<Record<string, string>> {
const now = Date.now();
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
@ -71,6 +215,25 @@ async function fetchChatCerts(): Promise<Record<string, string>> {
export type GoogleChatAudienceType = "app-url" | "project-number";
export type GoogleChatAuthMode = "auto" | "app" | "user";
export async function getGoogleChatAccessToken(
account: ResolvedGoogleChatAccount,
options?: { mode?: GoogleChatAuthMode },
): Promise<string> {
const mode = options?.mode ?? "auto";
if (mode === "user") {
return await getGoogleChatUserAccessToken(account);
}
if (mode === "app") {
return await getGoogleChatAppAccessToken(account);
}
if (account.appCredentialSource !== "none") {
return await getGoogleChatAppAccessToken(account);
}
return await getGoogleChatUserAccessToken(account);
}
export async function verifyGoogleChatRequest(params: {
bearer?: string | null;
audienceType?: GoogleChatAudienceType | null;

View File

@ -149,6 +149,15 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
clearBaseFields: [
"serviceAccount",
"serviceAccountFile",
"oauthClientId",
"oauthClientSecret",
"oauthRedirectUri",
"oauthClientFile",
"oauthRefreshToken",
"oauthRefreshTokenFile",
"oauthFromGog",
"gogAccount",
"gogClient",
"audienceType",
"audience",
"webhookPath",
@ -158,6 +167,12 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
],
}),
isConfigured: (account) => account.credentialSource !== "none",
unconfiguredReason: (account) => {
if (account.config.oauthFromGog) {
return "Google Chat OAuth from gog is enabled but no gog credentials were found. Ensure gog is installed, the gateway can access its keyring, or set oauthRefreshToken/oauthClientFile.";
}
return "Google Chat credentials are missing. Configure a service account or user OAuth.";
},
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
@ -298,10 +313,29 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.";
return "Google Chat env credentials can only be used for the default account.";
}
if (!input.useEnv && !input.token && !input.tokenFile) {
return "Google Chat requires --token (service account JSON) or --token-file.";
const hasServiceAccount = Boolean(input.token || input.tokenFile);
const hasOauthInput = Boolean(
input.oauthFromGog ||
input.oauthClientId ||
input.oauthClientSecret ||
input.oauthRedirectUri ||
input.oauthClientFile ||
input.oauthRefreshToken ||
input.oauthRefreshTokenFile,
);
if (!input.useEnv && !hasServiceAccount && !hasOauthInput) {
return "Google Chat requires service account JSON or OAuth credentials.";
}
if (hasOauthInput && !input.oauthFromGog) {
const hasClient =
Boolean(input.oauthClientFile) ||
(Boolean(input.oauthClientId) && Boolean(input.oauthClientSecret));
const hasRefresh = Boolean(input.oauthRefreshToken || input.oauthRefreshTokenFile);
if (!hasClient || !hasRefresh) {
return "Google Chat OAuth requires client id/secret (or --oauth-client-file) and a refresh token.";
}
}
return null;
},
@ -326,12 +360,30 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
: input.token
? { serviceAccount: input.token }
: {};
const oauthClientId = input.oauthClientId?.trim();
const oauthClientSecret = input.oauthClientSecret?.trim();
const oauthRedirectUri = input.oauthRedirectUri?.trim();
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();
const webhookUrl = input.webhookUrl?.trim();
const configPatch = {
...patch,
...(oauthClientId ? { oauthClientId } : {}),
...(oauthClientSecret ? { oauthClientSecret } : {}),
...(oauthRedirectUri ? { oauthRedirectUri } : {}),
...(oauthClientFile ? { oauthClientFile } : {}),
...(oauthRefreshToken ? { oauthRefreshToken } : {}),
...(oauthRefreshTokenFile ? { oauthRefreshTokenFile } : {}),
...(oauthFromGog ? { oauthFromGog } : {}),
...(gogAccount ? { gogAccount } : {}),
...(gogClient ? { gogClient } : {}),
...(audienceType ? { audienceType } : {}),
...(audience ? { audience } : {}),
...(webhookPath ? { webhookPath } : {}),
@ -491,6 +543,16 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
const configured = entry.configured === true;
if (!enabled || !configured) return [];
const issues = [];
if (entry.oauthFromGog && entry.userCredentialSource === "none") {
issues.push({
channel: "googlechat",
accountId,
kind: "auth",
message:
"Google Chat OAuth is set to reuse gog, but no gog OAuth credentials were detected.",
fix: "Ensure gog is installed and the keyring is unlocked (set GOG_KEYRING_PASSWORD), or set oauthRefreshToken/oauthClientFile manually.",
});
}
if (!entry.audience) {
issues.push({
channel: "googlechat",
@ -532,6 +594,8 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
enabled: account.enabled,
configured: account.credentialSource !== "none",
credentialSource: account.credentialSource,
oauthFromGog: account.config.oauthFromGog ?? false,
userCredentialSource: account.userCredentialSource,
audienceType: account.config.audienceType,
audience: account.config.audience,
webhookPath: account.config.webhookPath,

View File

@ -0,0 +1,48 @@
import fs from "node:fs";
/**
* Reads and parses a JSON file. Returns null if the file doesn't exist,
* is empty, or cannot be parsed. Logs meaningful errors for debugging.
*/
export function readJsonFile(filePath: string): unknown | null {
try {
const raw = fs.readFileSync(filePath, "utf8");
if (!raw.trim()) return null;
return JSON.parse(raw);
} catch (err) {
// Log meaningful errors (permission issues, malformed JSON) for debugging
if (err instanceof Error && err.message && !err.message.includes("ENOENT")) {
console.error(`Failed to read or parse JSON file ${filePath}`);
}
return null;
}
}
/**
* Extracts a refresh token from a JSON object that may have either
* `refresh_token` or `refreshToken` property.
*/
export function extractRefreshTokenFromRecord(record: Record<string, unknown>): string | null {
const token =
typeof record.refresh_token === "string"
? record.refresh_token.trim()
: typeof record.refreshToken === "string"
? record.refreshToken.trim()
: undefined;
if (token && token.length > 0) return token;
return null;
}
/**
* Reads a refresh token from a file. The file may contain either:
* - A plain string token
* - A JSON object with refresh_token or refreshToken property
*/
export function readRefreshTokenFromFile(filePath: string): string | null {
const raw = readJsonFile(filePath);
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (raw && typeof raw === "object") {
return extractRefreshTokenFromRecord(raw as Record<string, unknown>);
}
return null;
}

View File

@ -0,0 +1,218 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readJsonFile } from "./file-utils.js";
const tokenCache = new Map<string, string>();
function resolveWildcardJsonFile(
dirs: string[],
baseName: string,
suffix = ".json",
): string | null {
const matches: string[] = [];
for (const dir of dirs) {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
if (
!entry.name.startsWith(`${baseName}-`) ||
!entry.name.endsWith(suffix)
)
continue;
matches.push(path.join(dir, entry.name));
}
} catch {
// Ignore missing/permission issues and fall back to other dirs.
}
}
if (matches.length === 1) return matches[0];
return null;
}
function resolveGogJsonFile(
params: { gogClient?: string | null; gogAccount?: string | null },
baseName: string,
): 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, `${baseName}-${client}.json`));
}
}
if (domain) {
for (const dir of dirs) {
candidates.push(path.join(dir, `${baseName}-${domain}.json`));
}
}
for (const dir of dirs) {
candidates.push(path.join(dir, `${baseName}.json`));
}
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return resolveWildcardJsonFile(dirs, baseName);
}
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 {
return resolveGogJsonFile(params, "credentials");
}
function extractRefreshToken(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const refreshToken =
typeof record.refresh_token === "string"
? record.refresh_token
: typeof record.refreshToken === "string"
? record.refreshToken
: undefined;
const trimmed = refreshToken?.trim();
if (!trimmed) return null;
if (trimmed.startsWith("ya29.")) return null;
if (trimmed.startsWith("1//")) return trimmed;
return trimmed.length > 30 ? trimmed : null;
}
function parseTokenEmails(value: unknown): string[] {
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
const keys = Array.isArray(record.keys)
? record.keys.filter((entry): entry is string => typeof entry === "string")
: [];
const emails = new Set<string>();
for (const key of keys) {
const email = parseTokenEmail(key);
if (email) emails.add(email);
}
return Array.from(emails);
}
function parseTokenEmail(key: string): string | null {
const trimmed = key.trim();
if (!trimmed) return null;
const parts = trimmed.split(":");
if (parts.length < 2) return null;
if (parts[0] !== "token") return null;
if (parts.length === 2) return parts[1] || null;
return parts[2] || null;
}
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;
const env = {
...process.env,
...(params.gogAccount?.trim()
? { GOG_ACCOUNT: params.gogAccount.trim() }
: {}),
...(params.gogClient?.trim()
? { GOG_CLIENT: params.gogClient.trim() }
: {}),
};
const runGogJson = (args: string[]): unknown | null => {
try {
const stdout = execFileSync("gog", ["--no-input", "--json", ...args], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 3000,
env,
});
return JSON.parse(stdout);
} catch {
return null;
}
};
const explicitAccount = params.gogAccount?.trim();
let account = explicitAccount;
if (!account) {
const parsed = runGogJson(["auth", "tokens", "list"]);
const emails = parseTokenEmails(parsed);
if (emails.length === 1) {
account = emails[0];
} else {
return null;
}
}
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gog-"));
const outPath = path.join(tmpDir, "token.json");
try {
execFileSync(
"gog",
[
"--no-input",
"--json",
"auth",
"tokens",
"export",
account,
"--out",
outPath,
"--overwrite",
],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 5000,
env,
},
);
const parsed = readJsonFile(outPath);
const token = extractRefreshToken(parsed);
if (!token) return null;
tokenCache.set(cacheKey, token);
return token;
} catch {
return null;
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}

View File

@ -7,8 +7,10 @@ import {
type ResolvedGoogleChatAccount
} from "./accounts.js";
import {
createGoogleChatReaction,
downloadGoogleChatMedia,
deleteGoogleChatMessage,
deleteGoogleChatReaction,
sendGoogleChatMessage,
updateGoogleChatMessage,
} from "./api.js";
@ -642,18 +644,18 @@ async function processMessageWithPipeline(params: {
});
// Typing indicator setup
// Note: Reaction mode requires user OAuth, not available with service account auth.
// If reaction is configured, we fall back to message mode with a warning.
// Note: Reaction mode requires user OAuth. If unavailable, fall back to message mode.
let typingIndicator = account.config.typingIndicator ?? "message";
if (typingIndicator === "reaction") {
if (typingIndicator === "reaction" && account.userCredentialSource === "none") {
runtime.error?.(
`[${account.accountId}] typingIndicator="reaction" requires user OAuth (not supported with service account). Falling back to "message" mode.`,
`[${account.accountId}] typingIndicator="reaction" requires user OAuth. Configure OAuth (or oauthFromGog) to enable reactions; falling back to "message" mode.`,
);
typingIndicator = "message";
}
let typingMessageName: string | undefined;
let typingReactionName: string | undefined;
// Start typing indicator (message mode only, reaction mode not supported with app auth)
// Start typing indicator (message mode uses a temporary message; reaction mode uses 👀)
if (typingIndicator === "message") {
try {
const botName = resolveBotDisplayName({
@ -673,6 +675,21 @@ async function processMessageWithPipeline(params: {
}
}
if (typingIndicator === "reaction" && account.userCredentialSource !== "none") {
if (message.name) {
try {
const reaction = await createGoogleChatReaction({
account,
messageName: message.name,
emoji: "👀",
});
typingReactionName = reaction?.name;
} catch (err) {
runtime.error?.(`Failed sending typing reaction: ${String(err)}`);
}
}
}
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
@ -687,9 +704,11 @@ async function processMessageWithPipeline(params: {
config,
statusSink,
typingMessageName,
typingReactionName,
});
// Only use typing message for first delivery
typingMessageName = undefined;
typingReactionName = undefined;
},
onError: (err, info) => {
runtime.error?.(
@ -729,14 +748,36 @@ async function deliverGoogleChatReply(params: {
config: OpenClawConfig;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
typingMessageName?: string;
typingReactionName?: string;
}): Promise<void> {
const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params;
const {
payload,
account,
spaceId,
runtime,
core,
config,
statusSink,
typingMessageName,
typingReactionName,
} = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (typingReactionName) {
try {
await deleteGoogleChatReaction({
account,
reactionName: typingReactionName,
});
} catch (err) {
runtime.error?.(`Google Chat typing reaction cleanup failed: ${String(err)}`);
}
}
if (mediaList.length > 0) {
let suppressCaption = false;
if (typingMessageName) {

View File

@ -21,6 +21,11 @@ const channel = "googlechat" as const;
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
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";
const ENV_OAUTH_REFRESH_TOKEN = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN";
const ENV_OAUTH_REFRESH_TOKEN_FILE = "GOOGLE_CHAT_OAUTH_REFRESH_TOKEN_FILE";
function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) {
const allowFrom =
@ -138,10 +143,15 @@ async function promptCredentials(params: {
const envReady =
accountId === DEFAULT_ACCOUNT_ID &&
(Boolean(process.env[ENV_SERVICE_ACCOUNT]) ||
Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]));
Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]) ||
Boolean(process.env[ENV_OAUTH_CLIENT_ID]) ||
Boolean(process.env[ENV_OAUTH_CLIENT_SECRET]) ||
Boolean(process.env[ENV_OAUTH_CLIENT_FILE]) ||
Boolean(process.env[ENV_OAUTH_REFRESH_TOKEN]) ||
Boolean(process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]));
if (envReady) {
const useEnv = await prompter.confirm({
message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?",
message: "Use Google Chat env credentials?",
initialValue: true,
});
if (useEnv) {
@ -183,6 +193,101 @@ async function promptCredentials(params: {
});
}
async function promptOAuthCredentials(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<ClawdbotConfig> {
const { cfg, prompter, accountId } = params;
const envReady =
accountId === DEFAULT_ACCOUNT_ID &&
(Boolean(process.env[ENV_OAUTH_CLIENT_ID]) ||
Boolean(process.env[ENV_OAUTH_CLIENT_SECRET]) ||
Boolean(process.env[ENV_OAUTH_CLIENT_FILE]) ||
Boolean(process.env[ENV_OAUTH_REFRESH_TOKEN]) ||
Boolean(process.env[ENV_OAUTH_REFRESH_TOKEN_FILE]));
if (envReady) {
const useEnv = await prompter.confirm({
message: "Use Google Chat OAuth env credentials?",
initialValue: true,
});
if (useEnv) {
return applyAccountConfig({ cfg, accountId, patch: {} });
}
}
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: "gog",
});
let patch: Record<string, unknown> = {};
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",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
patch = { oauthClientFile: String(path).trim() };
} else {
const clientId = await prompter.text({
message: "OAuth client id",
placeholder: "123456.apps.googleusercontent.com",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const clientSecret = await prompter.text({
message: "OAuth client secret",
placeholder: "GOCSPX-...",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const redirectUri = await prompter.text({
message: "OAuth redirect URI (optional)",
placeholder: "https://your.host/googlechat/oauth/callback",
});
patch = {
oauthClientId: String(clientId).trim(),
oauthClientSecret: String(clientSecret).trim(),
...(String(redirectUri ?? "").trim() ? { oauthRedirectUri: String(redirectUri).trim() } : {}),
};
}
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,
...(refreshToken ? { oauthRefreshToken: String(refreshToken).trim() } : {}),
},
});
}
async function promptAudience(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
@ -218,8 +323,10 @@ async function promptAudience(params: {
async function noteGoogleChatSetup(prompter: WizardPrompter) {
await prompter.note(
[
"Google Chat apps use service-account auth and 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.",
"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"),
@ -238,7 +345,7 @@ export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
configured,
statusLines: [
`Google Chat: ${configured ? "configured" : "needs service account"}`,
`Google Chat: ${configured ? "configured" : "needs auth"}`,
],
selectionHint: configured ? "configured" : "needs auth",
};
@ -265,7 +372,19 @@ export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = {
let next = cfg;
await noteGoogleChatSetup(prompter);
next = await promptCredentials({ cfg: next, prompter, accountId });
const authMethod = await prompter.select({
message: "Configure Google Chat credentials",
options: [
{ value: "service-account", label: "Service account (bot auth)" },
{ value: "oauth", label: "User OAuth (reactions + user actions)" },
],
initialValue: "service-account",
});
if (authMethod === "oauth") {
next = await promptOAuthCredentials({ cfg: next, prompter, accountId });
} else {
next = await promptCredentials({ cfg: next, prompter, accountId });
}
next = await promptAudience({ cfg: next, prompter, accountId });
const namedConfig = migrateBaseNameToDefaultAccount({

View File

@ -35,6 +35,15 @@ export type ChannelSetupInput = {
webhookUrl?: string;
audienceType?: string;
audience?: string;
oauthClientId?: string;
oauthClientSecret?: string;
oauthRedirectUri?: string;
oauthClientFile?: string;
oauthRefreshToken?: string;
oauthRefreshTokenFile?: string;
oauthFromGog?: boolean;
gogAccount?: string;
gogClient?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;

View File

@ -38,6 +38,15 @@ const optionNamesAdd = [
"webhookUrl",
"audienceType",
"audience",
"oauthClientId",
"oauthClientSecret",
"oauthRedirectUri",
"oauthClientFile",
"oauthRefreshToken",
"oauthRefreshTokenFile",
"oauthFromGog",
"gogAccount",
"gogClient",
"useEnv",
"homeserver",
"userId",
@ -175,6 +184,15 @@ export function registerChannelsCli(program: Command) {
.option("--webhook-url <url>", "Google Chat webhook URL")
.option("--audience-type <type>", "Google Chat audience type (app-url|project-number)")
.option("--audience <value>", "Google Chat audience value (app URL or project number)")
.option("--oauth-client-id <id>", "Google Chat OAuth client ID")
.option("--oauth-client-secret <secret>", "Google Chat OAuth client secret")
.option("--oauth-redirect-uri <uri>", "Google Chat OAuth redirect URI")
.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-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("--user-id <id>", "Matrix user ID")
.option("--access-token <token>", "Matrix access token")

View File

@ -39,6 +39,15 @@ export function applyChannelAccountConfig(params: {
webhookUrl?: string;
audienceType?: string;
audience?: string;
oauthClientId?: string;
oauthClientSecret?: string;
oauthRedirectUri?: string;
oauthClientFile?: string;
oauthRefreshToken?: string;
oauthRefreshTokenFile?: string;
oauthFromGog?: boolean;
gogAccount?: string;
gogClient?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;
@ -76,6 +85,15 @@ export function applyChannelAccountConfig(params: {
webhookUrl: params.webhookUrl,
audienceType: params.audienceType,
audience: params.audience,
oauthClientId: params.oauthClientId,
oauthClientSecret: params.oauthClientSecret,
oauthRedirectUri: params.oauthRedirectUri,
oauthClientFile: params.oauthClientFile,
oauthRefreshToken: params.oauthRefreshToken,
oauthRefreshTokenFile: params.oauthRefreshTokenFile,
oauthFromGog: params.oauthFromGog,
gogAccount: params.gogAccount,
gogClient: params.gogClient,
useEnv: params.useEnv,
homeserver: params.homeserver,
userId: params.userId,

View File

@ -36,6 +36,15 @@ export type ChannelsAddOptions = {
webhookUrl?: string;
audienceType?: string;
audience?: string;
oauthClientId?: string;
oauthClientSecret?: string;
oauthRedirectUri?: string;
oauthClientFile?: string;
oauthRefreshToken?: string;
oauthRefreshTokenFile?: string;
oauthFromGog?: boolean;
gogAccount?: string;
gogClient?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;
@ -204,6 +213,15 @@ export async function channelsAddCommand(
webhookUrl: opts.webhookUrl,
audienceType: opts.audienceType,
audience: opts.audience,
oauthClientId: opts.oauthClientId,
oauthClientSecret: opts.oauthClientSecret,
oauthRedirectUri: opts.oauthRedirectUri,
oauthClientFile: opts.oauthClientFile,
oauthRefreshToken: opts.oauthRefreshToken,
oauthRefreshTokenFile: opts.oauthRefreshTokenFile,
oauthFromGog: opts.oauthFromGog,
gogAccount: opts.gogAccount,
gogClient: opts.gogClient,
homeserver: opts.homeserver,
userId: opts.userId,
accessToken: opts.accessToken,
@ -247,6 +265,15 @@ export async function channelsAddCommand(
webhookUrl: opts.webhookUrl,
audienceType: opts.audienceType,
audience: opts.audience,
oauthClientId: opts.oauthClientId,
oauthClientSecret: opts.oauthClientSecret,
oauthRedirectUri: opts.oauthRedirectUri,
oauthClientFile: opts.oauthClientFile,
oauthRefreshToken: opts.oauthRefreshToken,
oauthRefreshTokenFile: opts.oauthRefreshTokenFile,
oauthFromGog: opts.oauthFromGog,
gogAccount: opts.gogAccount,
gogClient: opts.gogClient,
homeserver: opts.homeserver,
userId: opts.userId,
accessToken: opts.accessToken,

View File

@ -60,6 +60,24 @@ export type GoogleChatAccountConfig = {
serviceAccount?: string | Record<string, unknown>;
/** Service account JSON file path. */
serviceAccountFile?: string;
/** OAuth client id (user auth). */
oauthClientId?: string;
/** OAuth client secret (user auth). */
oauthClientSecret?: string;
/** OAuth redirect URI (user auth). */
oauthRedirectUri?: string;
/** OAuth client JSON file path (user auth). */
oauthClientFile?: string;
/** OAuth refresh token (user auth). */
oauthRefreshToken?: string;
/** OAuth refresh token file path (user auth). */
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). */
audienceType?: "app-url" | "project-number";
/** Audience value (app URL or project number). */

View File

@ -323,6 +323,15 @@ export const GoogleChatAccountSchema = z
groups: z.record(z.string(), GoogleChatGroupSchema.optional()).optional(),
serviceAccount: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
serviceAccountFile: z.string().optional(),
oauthClientId: z.string().optional(),
oauthClientSecret: z.string().optional(),
oauthRedirectUri: z.string().optional(),
oauthClientFile: z.string().optional(),
oauthRefreshToken: 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(),
audience: z.string().optional(),
webhookPath: z.string().optional(),