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).
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
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 +157,13 @@ 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 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 +191,10 @@ 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`.
- 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

@ -1135,6 +1135,8 @@ 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...",
audienceType: "app-url", // app-url | project-number
audience: "https://gateway.example.com/googlechat",
webhookPath: "/googlechat",
@ -1159,6 +1161,11 @@ 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`.
- `audienceType` + `audience` must match the Chat apps webhook auth config.
- 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";
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 +13,19 @@ 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_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[] {
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: {
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 +116,43 @@ function resolveCredentialsFromConfig(params: {
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: {
cfg: ClawdbotConfig;
accountId?: string | null;
@ -114,13 +164,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: ClawdbotConfig) {
function isReactionsEnabled(accounts: ReturnType<typeof listEnabledAccounts>, cfg: ClawdbotConfig) {
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,4 +1,7 @@
import fs from "node:fs";
import { GoogleAuth, OAuth2Client } from "google-auth-library";
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
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";
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,155 @@ 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";
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>> {
const now = Date.now();
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 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,12 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
clearBaseFields: [
"serviceAccount",
"serviceAccountFile",
"oauthClientId",
"oauthClientSecret",
"oauthRedirectUri",
"oauthClientFile",
"oauthRefreshToken",
"oauthRefreshTokenFile",
"audienceType",
"audience",
"webhookPath",
@ -298,10 +304,28 @@ 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.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;
},
@ -326,12 +350,24 @@ 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 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 } : {}),
...(audienceType ? { audienceType } : {}),
...(audience ? { audience } : {}),
...(webhookPath ? { webhookPath } : {}),

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.`,
);
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: ClawdbotConfig;
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: ClawdbotConfig, 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,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: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
@ -218,8 +305,9 @@ 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.",
"Webhook verification requires audience type + audience value.",
`Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`,
].join("\n"),
@ -238,7 +326,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 +353,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,12 @@ export type ChannelSetupInput = {
webhookUrl?: string;
audienceType?: string;
audience?: string;
oauthClientId?: string;
oauthClientSecret?: string;
oauthRedirectUri?: string;
oauthClientFile?: string;
oauthRefreshToken?: string;
oauthRefreshTokenFile?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;

View File

@ -38,6 +38,12 @@ const optionNamesAdd = [
"webhookUrl",
"audienceType",
"audience",
"oauthClientId",
"oauthClientSecret",
"oauthRedirectUri",
"oauthClientFile",
"oauthRefreshToken",
"oauthRefreshTokenFile",
"useEnv",
"homeserver",
"userId",
@ -175,6 +181,12 @@ 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("--homeserver <url>", "Matrix homeserver URL")
.option("--user-id <id>", "Matrix user ID")
.option("--access-token <token>", "Matrix access token")

View File

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

View File

@ -36,6 +36,12 @@ export type ChannelsAddOptions = {
webhookUrl?: string;
audienceType?: string;
audience?: string;
oauthClientId?: string;
oauthClientSecret?: string;
oauthRedirectUri?: string;
oauthClientFile?: string;
oauthRefreshToken?: string;
oauthRefreshTokenFile?: string;
useEnv?: boolean;
homeserver?: string;
userId?: string;
@ -204,6 +210,12 @@ 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,
homeserver: opts.homeserver,
userId: opts.userId,
accessToken: opts.accessToken,
@ -247,6 +259,12 @@ 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,
homeserver: opts.homeserver,
userId: opts.userId,
accessToken: opts.accessToken,

View File

@ -60,6 +60,18 @@ 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;
/** Webhook audience type (app-url or project-number). */
audienceType?: "app-url" | "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(),
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(),
audienceType: z.enum(["app-url", "project-number"]).optional(),
audience: z.string().optional(),
webhookPath: z.string().optional(),