feat: add google chat user oauth support

This commit is contained in:
iHildy 2026-01-26 17:08:37 -06:00
parent 820ab8765a
commit e83e4bf28c
16 changed files with 572 additions and 38 deletions

View File

@ -46,6 +46,19 @@ Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
8) Set the webhook audience type + value (matches your Chat app config). 8) Set the webhook audience type + value (matches your Chat app config).
9) Start the gateway. Google Chat will POST to your webhook path. 9) Start the gateway. Google Chat will POST to your webhook path.
## User OAuth (optional, enables reactions)
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.
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 ## Add to Google Chat
Once the gateway is running and your email is added to the visibility list: Once the gateway is running and your email is added to the visibility list:
1) Go to [Google Chat](https://chat.google.com/). 1) Go to [Google Chat](https://chat.google.com/).
@ -144,6 +157,13 @@ Use these identifiers for delivery and allowlists:
"googlechat": { "googlechat": {
enabled: true, enabled: true,
serviceAccountFile: "/path/to/service-account.json", serviceAccountFile: "/path/to/service-account.json",
// Optional: user OAuth for reactions + user-attributed actions
oauthClientFile: "/path/to/oauth-client.json",
oauthRefreshToken: "1//0g...",
// Or explicit fields:
// oauthClientId: "123456.apps.googleusercontent.com",
// oauthClientSecret: "GOCSPX-...",
// oauthRedirectUri: "https://your.host/googlechat/oauth/callback",
audienceType: "app-url", audienceType: "app-url",
audience: "https://gateway.example.com/googlechat", audience: "https://gateway.example.com/googlechat",
webhookPath: "/googlechat", webhookPath: "/googlechat",
@ -171,6 +191,10 @@ Use these identifiers for delivery and allowlists:
Notes: Notes:
- Service account credentials can also be passed inline with `serviceAccount` (JSON string). - 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`.
- Default webhook path is `/googlechat` if `webhookPath` isnt set. - Default webhook path is `/googlechat` if `webhookPath` isnt set.
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled. - Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth). - `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).

View File

@ -1135,6 +1135,8 @@ Multi-account support lives under `channels.googlechat.accounts` (see the multi-
"googlechat": { "googlechat": {
enabled: true, enabled: true,
serviceAccountFile: "/path/to/service-account.json", serviceAccountFile: "/path/to/service-account.json",
oauthClientFile: "/path/to/oauth-client.json",
oauthRefreshToken: "1//0g...",
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",
@ -1159,6 +1161,11 @@ Multi-account support lives under `channels.googlechat.accounts` (see the multi-
Notes: Notes:
- Service account JSON can be inline (`serviceAccount`) or file-based (`serviceAccountFile`). - 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`. - 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`.
- `audienceType` + `audience` must match the Chat apps webhook auth config. - `audienceType` + `audience` must match the Chat apps webhook auth config.
- Use `spaces/<spaceId>` or `users/<userId|email>` when setting delivery targets. - Use `spaces/<spaceId>` or `users/<userId|email>` when setting delivery targets.

View File

@ -3,7 +3,9 @@ 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";
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 = { export type ResolvedGoogleChatAccount = {
accountId: string; accountId: string;
@ -11,12 +13,19 @@ export type ResolvedGoogleChatAccount = {
enabled: boolean; enabled: boolean;
config: GoogleChatAccountConfig; config: GoogleChatAccountConfig;
credentialSource: GoogleChatCredentialSource; credentialSource: GoogleChatCredentialSource;
appCredentialSource: GoogleChatAppCredentialSource;
userCredentialSource: GoogleChatUserCredentialSource;
credentials?: Record<string, unknown>; credentials?: Record<string, unknown>;
credentialsFile?: string; credentialsFile?: string;
}; };
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_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: ClawdbotConfig): string[] { function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts; const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts;
@ -69,13 +78,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: { function resolveCredentialsFromConfig(params: {
accountId: string; accountId: string;
account: GoogleChatAccountConfig; account: GoogleChatAccountConfig;
}): { }): {
credentials?: Record<string, unknown>; credentials?: Record<string, unknown>;
credentialsFile?: string; credentialsFile?: string;
source: GoogleChatCredentialSource; source: GoogleChatAppCredentialSource;
} { } {
const { account, accountId } = params; const { account, accountId } = params;
const inline = parseServiceAccount(account.serviceAccount); const inline = parseServiceAccount(account.serviceAccount);
@ -103,6 +116,43 @@ function resolveCredentialsFromConfig(params: {
return { source: "none" }; return { source: "none" };
} }
function resolveUserAuthSource(params: {
accountId: string;
account: GoogleChatAccountConfig;
}): GoogleChatUserCredentialSource {
const { account, accountId } = params;
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 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;
const hasRefresh = hasInlineRefresh || hasFileRefresh || hasEnvRefresh || hasEnvRefreshFile;
if (!hasClient || !hasRefresh) return "none";
if (hasEnvClient || hasEnvClientFile || hasEnvRefresh || hasEnvRefreshFile) return "env";
if (hasFileClient || hasFileRefresh) return "file";
return "inline";
}
export function resolveGoogleChatAccount(params: { export function resolveGoogleChatAccount(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
accountId?: string | null; accountId?: string | null;
@ -114,13 +164,22 @@ export function resolveGoogleChatAccount(params: {
const accountEnabled = merged.enabled !== false; const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled; const enabled = baseEnabled && accountEnabled;
const credentials = resolveCredentialsFromConfig({ accountId, account: merged }); const credentials = resolveCredentialsFromConfig({ accountId, account: merged });
const userCredentialSource = resolveUserAuthSource({ accountId, account: merged });
const credentialSource =
credentials.source !== "none"
? credentials.source
: userCredentialSource !== "none"
? "oauth"
: "none";
return { return {
accountId, accountId,
name: merged.name?.trim() || undefined, name: merged.name?.trim() || undefined,
enabled, enabled,
config: merged, config: merged,
credentialSource: credentials.source, credentialSource,
appCredentialSource: credentials.source,
userCredentialSource,
credentials: credentials.credentials, credentials: credentials.credentials,
credentialsFile: credentials.credentialsFile, credentialsFile: credentials.credentialsFile,
}; };

View File

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

View File

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

View File

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

View File

@ -1,4 +1,7 @@
import fs from "node:fs";
import { GoogleAuth, OAuth2Client } from "google-auth-library"; import { GoogleAuth, OAuth2Client } from "google-auth-library";
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
import type { ResolvedGoogleChatAccount } from "./accounts.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js";
@ -10,6 +13,7 @@ const CHAT_CERTS_URL =
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com"; "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
const authCache = new Map<string, { key: string; auth: GoogleAuth }>(); const authCache = new Map<string, { key: string; auth: GoogleAuth }>();
const oauthCache = new Map<string, { key: string; client: OAuth2Client }>();
const verifyClient = new OAuth2Client(); const verifyClient = new OAuth2Client();
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null; let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
@ -42,7 +46,7 @@ function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
return auth; return auth;
} }
export async function getGoogleChatAccessToken( export async function getGoogleChatAppAccessToken(
account: ResolvedGoogleChatAccount, account: ResolvedGoogleChatAccount,
): Promise<string> { ): Promise<string> {
const auth = getAuthInstance(account); const auth = getAuthInstance(account);
@ -55,6 +59,155 @@ export async function getGoogleChatAccessToken(
return token; 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";
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 readJsonFile(path: string): unknown | null {
try {
const raw = fs.readFileSync(path, "utf8");
if (!raw.trim()) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
function resolveOAuthClientConfig(account: ResolvedGoogleChatAccount): OAuthClientConfig | null {
const cfg = account.config;
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 (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;
if (cfg.oauthRefreshToken?.trim()) return cfg.oauthRefreshToken.trim();
const tokenFile = cfg.oauthRefreshTokenFile?.trim();
if (tokenFile) {
const raw = readJsonFile(tokenFile);
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>;
const token =
typeof record.refresh_token === "string"
? record.refresh_token.trim()
: typeof record.refreshToken === "string"
? record.refreshToken.trim()
: "";
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 raw = readJsonFile(envFile);
if (typeof raw === "string" && raw.trim()) return raw.trim();
if (raw && typeof raw === "object") {
const record = raw as Record<string, unknown>;
const token =
typeof record.refresh_token === "string"
? record.refresh_token.trim()
: typeof record.refreshToken === "string"
? record.refreshToken.trim()
: "";
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>> { async function fetchChatCerts(): Promise<Record<string, string>> {
const now = Date.now(); const now = Date.now();
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) { if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
@ -71,6 +224,25 @@ async function fetchChatCerts(): Promise<Record<string, string>> {
export type GoogleChatAudienceType = "app-url" | "project-number"; 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: { export async function verifyGoogleChatRequest(params: {
bearer?: string | null; bearer?: string | null;
audienceType?: GoogleChatAudienceType | null; audienceType?: GoogleChatAudienceType | null;

View File

@ -149,6 +149,12 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
clearBaseFields: [ clearBaseFields: [
"serviceAccount", "serviceAccount",
"serviceAccountFile", "serviceAccountFile",
"oauthClientId",
"oauthClientSecret",
"oauthRedirectUri",
"oauthClientFile",
"oauthRefreshToken",
"oauthRefreshTokenFile",
"audienceType", "audienceType",
"audience", "audience",
"webhookPath", "webhookPath",
@ -298,10 +304,28 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
}), }),
validateInput: ({ accountId, input }) => { validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { 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) { const hasServiceAccount = Boolean(input.token || input.tokenFile);
return "Google Chat requires --token (service account JSON) or --token-file."; const hasOauthInput = Boolean(
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) {
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; return null;
}, },
@ -326,12 +350,24 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
: input.token : input.token
? { serviceAccount: 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 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();
const webhookUrl = input.webhookUrl?.trim(); const webhookUrl = input.webhookUrl?.trim();
const configPatch = { const configPatch = {
...patch, ...patch,
...(oauthClientId ? { oauthClientId } : {}),
...(oauthClientSecret ? { oauthClientSecret } : {}),
...(oauthRedirectUri ? { oauthRedirectUri } : {}),
...(oauthClientFile ? { oauthClientFile } : {}),
...(oauthRefreshToken ? { oauthRefreshToken } : {}),
...(oauthRefreshTokenFile ? { oauthRefreshTokenFile } : {}),
...(audienceType ? { audienceType } : {}), ...(audienceType ? { audienceType } : {}),
...(audience ? { audience } : {}), ...(audience ? { audience } : {}),
...(webhookPath ? { webhookPath } : {}), ...(webhookPath ? { webhookPath } : {}),

View File

@ -7,8 +7,10 @@ import {
type ResolvedGoogleChatAccount type ResolvedGoogleChatAccount
} from "./accounts.js"; } from "./accounts.js";
import { import {
createGoogleChatReaction,
downloadGoogleChatMedia, downloadGoogleChatMedia,
deleteGoogleChatMessage, deleteGoogleChatMessage,
deleteGoogleChatReaction,
sendGoogleChatMessage, sendGoogleChatMessage,
updateGoogleChatMessage, updateGoogleChatMessage,
} from "./api.js"; } from "./api.js";
@ -642,18 +644,18 @@ async function processMessageWithPipeline(params: {
}); });
// Typing indicator setup // Typing indicator setup
// Note: Reaction mode requires user OAuth, not available with service account auth. // Note: Reaction mode requires user OAuth. If unavailable, fall back to message mode.
// If reaction is configured, we fall back to message mode with a warning.
let typingIndicator = account.config.typingIndicator ?? "message"; let typingIndicator = account.config.typingIndicator ?? "message";
if (typingIndicator === "reaction") { if (typingIndicator === "reaction" && account.userCredentialSource === "none") {
runtime.error?.( 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 (not supported with service account). Falling back to "message" mode.`,
); );
typingIndicator = "message"; typingIndicator = "message";
} }
let typingMessageName: string | undefined; 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") { if (typingIndicator === "message") {
try { try {
const botName = resolveBotDisplayName({ 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({ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload, ctx: ctxPayload,
cfg: config, cfg: config,
@ -687,9 +704,11 @@ async function processMessageWithPipeline(params: {
config, config,
statusSink, statusSink,
typingMessageName, typingMessageName,
typingReactionName,
}); });
// Only use typing message for first delivery // Only use typing message for first delivery
typingMessageName = undefined; typingMessageName = undefined;
typingReactionName = undefined;
}, },
onError: (err, info) => { onError: (err, info) => {
runtime.error?.( runtime.error?.(
@ -729,14 +748,36 @@ async function deliverGoogleChatReply(params: {
config: ClawdbotConfig; config: ClawdbotConfig;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
typingMessageName?: string; typingMessageName?: string;
typingReactionName?: string;
}): Promise<void> { }): 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 const mediaList = payload.mediaUrls?.length
? payload.mediaUrls ? payload.mediaUrls
: payload.mediaUrl : payload.mediaUrl
? [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) { if (mediaList.length > 0) {
let suppressCaption = false; let suppressCaption = false;
if (typingMessageName) { 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 = "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_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: ClawdbotConfig, policy: DmPolicy) { function setGoogleChatDmPolicy(cfg: ClawdbotConfig, policy: DmPolicy) {
const allowFrom = const allowFrom =
@ -138,10 +143,15 @@ async function promptCredentials(params: {
const envReady = const envReady =
accountId === DEFAULT_ACCOUNT_ID && accountId === DEFAULT_ACCOUNT_ID &&
(Boolean(process.env[ENV_SERVICE_ACCOUNT]) || (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) { if (envReady) {
const useEnv = await prompter.confirm({ const useEnv = await prompter.confirm({
message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?", message: "Use Google Chat env credentials?",
initialValue: true, initialValue: true,
}); });
if (useEnv) { if (useEnv) {
@ -183,6 +193,83 @@ 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: "file", label: "OAuth client JSON file" },
{ value: "manual", label: "OAuth client id + secret" },
],
initialValue: "file",
});
let patch: Record<string, unknown> = {};
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 = 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(),
},
});
}
async function promptAudience(params: { async function promptAudience(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
prompter: WizardPrompter; prompter: WizardPrompter;
@ -218,8 +305,9 @@ async function promptAudience(params: {
async function noteGoogleChatSetup(prompter: WizardPrompter) { async function noteGoogleChatSetup(prompter: WizardPrompter) {
await prompter.note( 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.", "Set the Chat API scopes in your service account and configure the Chat app URL.",
"User OAuth enables reactions and other user-level APIs.",
"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"),
@ -238,7 +326,7 @@ export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = {
channel, channel,
configured, configured,
statusLines: [ statusLines: [
`Google Chat: ${configured ? "configured" : "needs service account"}`, `Google Chat: ${configured ? "configured" : "needs auth"}`,
], ],
selectionHint: configured ? "configured" : "needs auth", selectionHint: configured ? "configured" : "needs auth",
}; };
@ -265,7 +353,19 @@ export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = {
let next = cfg; let next = cfg;
await noteGoogleChatSetup(prompter); 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 }); next = await promptAudience({ cfg: next, prompter, accountId });
const namedConfig = migrateBaseNameToDefaultAccount({ const namedConfig = migrateBaseNameToDefaultAccount({

View File

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

View File

@ -38,6 +38,12 @@ const optionNamesAdd = [
"webhookUrl", "webhookUrl",
"audienceType", "audienceType",
"audience", "audience",
"oauthClientId",
"oauthClientSecret",
"oauthRedirectUri",
"oauthClientFile",
"oauthRefreshToken",
"oauthRefreshTokenFile",
"useEnv", "useEnv",
"homeserver", "homeserver",
"userId", "userId",
@ -175,6 +181,12 @@ export function registerChannelsCli(program: Command) {
.option("--webhook-url <url>", "Google Chat webhook URL") .option("--webhook-url <url>", "Google Chat webhook URL")
.option("--audience-type <type>", "Google Chat audience type (app-url|project-number)") .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("--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("--homeserver <url>", "Matrix homeserver URL") .option("--homeserver <url>", "Matrix homeserver URL")
.option("--user-id <id>", "Matrix user ID") .option("--user-id <id>", "Matrix user ID")
.option("--access-token <token>", "Matrix access token") .option("--access-token <token>", "Matrix access token")

View File

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

View File

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

View File

@ -60,6 +60,18 @@ export type GoogleChatAccountConfig = {
serviceAccount?: string | Record<string, unknown>; serviceAccount?: string | Record<string, unknown>;
/** Service account JSON file path. */ /** Service account JSON file path. */
serviceAccountFile?: string; 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;
/** Webhook audience type (app-url or project-number). */ /** Webhook audience type (app-url or project-number). */
audienceType?: "app-url" | "project-number"; audienceType?: "app-url" | "project-number";
/** Audience value (app URL or project number). */ /** Audience value (app URL or project number). */

View File

@ -311,6 +311,12 @@ export const GoogleChatAccountSchema = z
groups: z.record(z.string(), GoogleChatGroupSchema.optional()).optional(), groups: z.record(z.string(), GoogleChatGroupSchema.optional()).optional(),
serviceAccount: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(), serviceAccount: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
serviceAccountFile: z.string().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(),
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(),