Merge pull request #1 from iHildy/feat/googlechat-user-oauth
Feat/googlechat user oauth
This commit is contained in:
commit
538c41d2a9
@ -5,9 +5,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Google Chat (Chat API)
|
# Google Chat (Chat API)
|
||||||
|
|
||||||
Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
|
## Service Account Setup
|
||||||
|
|
||||||
## Quick setup (beginner)
|
|
||||||
1) Create a Google Cloud project and enable the **Google Chat API**.
|
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)
|
- 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.
|
- 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).
|
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 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
|
## 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 +207,17 @@ 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 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",
|
audienceType: "app-url",
|
||||||
audience: "https://gateway.example.com/googlechat",
|
audience: "https://gateway.example.com/googlechat",
|
||||||
webhookPath: "/googlechat",
|
webhookPath: "/googlechat",
|
||||||
@ -171,6 +245,11 @@ 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`.
|
||||||
|
- `oauthFromGog` reuses the `gog` keyring. Use `gogAccount`/`gogClient` (or `GOG_ACCOUNT`/`GOG_CLIENT`) when multiple accounts or clients exist.
|
||||||
- Default webhook path is `/googlechat` if `webhookPath` isn’t set.
|
- Default webhook path is `/googlechat` if `webhookPath` isn’t set.
|
||||||
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
|
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
|
||||||
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
|
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
|
||||||
|
|||||||
@ -1140,6 +1140,12 @@ 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...",
|
||||||
|
// Or reuse gog OAuth:
|
||||||
|
// oauthFromGog: true,
|
||||||
|
// gogAccount: "you@example.com",
|
||||||
|
// gogClient: "work",
|
||||||
audienceType: "app-url", // app-url | project-number
|
audienceType: "app-url", // app-url | project-number
|
||||||
audience: "https://gateway.example.com/googlechat",
|
audience: "https://gateway.example.com/googlechat",
|
||||||
webhookPath: "/googlechat",
|
webhookPath: "/googlechat",
|
||||||
@ -1164,6 +1170,12 @@ 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`.
|
||||||
|
- `oauthFromGog` reuses `gog` OAuth credentials; `gogAccount`/`gogClient` (or `GOG_ACCOUNT`/`GOG_CLIENT`) select the account/client.
|
||||||
- `audienceType` + `audience` must match the Chat app’s webhook auth config.
|
- `audienceType` + `audience` must match the Chat app’s webhook auth config.
|
||||||
- Use `spaces/<spaceId>` or `users/<userId|email>` when setting delivery targets.
|
- Use `spaces/<spaceId>` or `users/<userId|email>` when setting delivery targets.
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,11 @@ import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
|||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.js";
|
import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.js";
|
||||||
|
import { readGogRefreshTokenSync, resolveGogCredentialsFile } from "./gog.js";
|
||||||
|
|
||||||
export type 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 +14,21 @@ 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_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: 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 +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: {
|
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 +119,53 @@ function resolveCredentialsFromConfig(params: {
|
|||||||
return { source: "none" };
|
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: {
|
export function resolveGoogleChatAccount(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
@ -114,13 +177,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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
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";
|
||||||
|
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_SCOPE = "https://www.googleapis.com/auth/chat.bot";
|
||||||
const CHAT_ISSUER = "chat@system.gserviceaccount.com";
|
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";
|
"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,146 @@ 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";
|
||||||
|
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>> {
|
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 +215,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;
|
||||||
|
|||||||
@ -149,6 +149,15 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
clearBaseFields: [
|
clearBaseFields: [
|
||||||
"serviceAccount",
|
"serviceAccount",
|
||||||
"serviceAccountFile",
|
"serviceAccountFile",
|
||||||
|
"oauthClientId",
|
||||||
|
"oauthClientSecret",
|
||||||
|
"oauthRedirectUri",
|
||||||
|
"oauthClientFile",
|
||||||
|
"oauthRefreshToken",
|
||||||
|
"oauthRefreshTokenFile",
|
||||||
|
"oauthFromGog",
|
||||||
|
"gogAccount",
|
||||||
|
"gogClient",
|
||||||
"audienceType",
|
"audienceType",
|
||||||
"audience",
|
"audience",
|
||||||
"webhookPath",
|
"webhookPath",
|
||||||
@ -158,6 +167,12 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
isConfigured: (account) => account.credentialSource !== "none",
|
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) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
@ -298,10 +313,29 @@ 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.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;
|
return null;
|
||||||
},
|
},
|
||||||
@ -326,12 +360,30 @@ 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 oauthFromGog = input.oauthFromGog === true ? true : undefined;
|
||||||
|
const gogAccount = input.gogAccount?.trim();
|
||||||
|
const gogClient = input.gogClient?.trim();
|
||||||
const audienceType = input.audienceType?.trim();
|
const audienceType = input.audienceType?.trim();
|
||||||
const audience = input.audience?.trim();
|
const audience = input.audience?.trim();
|
||||||
const webhookPath = input.webhookPath?.trim();
|
const webhookPath = input.webhookPath?.trim();
|
||||||
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 } : {}),
|
||||||
|
...(oauthFromGog ? { oauthFromGog } : {}),
|
||||||
|
...(gogAccount ? { gogAccount } : {}),
|
||||||
|
...(gogClient ? { gogClient } : {}),
|
||||||
...(audienceType ? { audienceType } : {}),
|
...(audienceType ? { audienceType } : {}),
|
||||||
...(audience ? { audience } : {}),
|
...(audience ? { audience } : {}),
|
||||||
...(webhookPath ? { webhookPath } : {}),
|
...(webhookPath ? { webhookPath } : {}),
|
||||||
@ -491,6 +543,16 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
const configured = entry.configured === true;
|
const configured = entry.configured === true;
|
||||||
if (!enabled || !configured) return [];
|
if (!enabled || !configured) return [];
|
||||||
const issues = [];
|
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) {
|
if (!entry.audience) {
|
||||||
issues.push({
|
issues.push({
|
||||||
channel: "googlechat",
|
channel: "googlechat",
|
||||||
@ -532,6 +594,8 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
enabled: account.enabled,
|
enabled: account.enabled,
|
||||||
configured: account.credentialSource !== "none",
|
configured: account.credentialSource !== "none",
|
||||||
credentialSource: account.credentialSource,
|
credentialSource: account.credentialSource,
|
||||||
|
oauthFromGog: account.config.oauthFromGog ?? false,
|
||||||
|
userCredentialSource: account.userCredentialSource,
|
||||||
audienceType: account.config.audienceType,
|
audienceType: account.config.audienceType,
|
||||||
audience: account.config.audience,
|
audience: account.config.audience,
|
||||||
webhookPath: account.config.webhookPath,
|
webhookPath: account.config.webhookPath,
|
||||||
|
|||||||
48
extensions/googlechat/src/file-utils.ts
Normal file
48
extensions/googlechat/src/file-utils.ts
Normal 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;
|
||||||
|
}
|
||||||
218
extensions/googlechat/src/gog.ts
Normal file
218
extensions/googlechat/src/gog.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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. Configure OAuth (or oauthFromGog) to enable reactions; 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) {
|
||||||
|
|||||||
@ -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,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: {
|
async function promptAudience(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
prompter: WizardPrompter;
|
prompter: WizardPrompter;
|
||||||
@ -218,8 +323,10 @@ 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.",
|
||||||
|
"If gog is configured, you can reuse its OAuth credentials for Chat.",
|
||||||
"Webhook verification requires audience type + audience value.",
|
"Webhook verification requires audience type + audience value.",
|
||||||
`Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`,
|
`Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
@ -238,7 +345,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 +372,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({
|
||||||
|
|||||||
@ -35,6 +35,15 @@ 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;
|
||||||
|
oauthFromGog?: boolean;
|
||||||
|
gogAccount?: string;
|
||||||
|
gogClient?: string;
|
||||||
useEnv?: boolean;
|
useEnv?: boolean;
|
||||||
homeserver?: string;
|
homeserver?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|||||||
@ -38,6 +38,15 @@ const optionNamesAdd = [
|
|||||||
"webhookUrl",
|
"webhookUrl",
|
||||||
"audienceType",
|
"audienceType",
|
||||||
"audience",
|
"audience",
|
||||||
|
"oauthClientId",
|
||||||
|
"oauthClientSecret",
|
||||||
|
"oauthRedirectUri",
|
||||||
|
"oauthClientFile",
|
||||||
|
"oauthRefreshToken",
|
||||||
|
"oauthRefreshTokenFile",
|
||||||
|
"oauthFromGog",
|
||||||
|
"gogAccount",
|
||||||
|
"gogClient",
|
||||||
"useEnv",
|
"useEnv",
|
||||||
"homeserver",
|
"homeserver",
|
||||||
"userId",
|
"userId",
|
||||||
@ -175,6 +184,15 @@ 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("--oauth-from-gog", "Reuse gog OAuth credentials for Google Chat", false)
|
||||||
|
.option("--gog-account <email>", "gog account email to match refresh token")
|
||||||
|
.option("--gog-client <client>", "gog client name to match credentials file")
|
||||||
.option("--homeserver <url>", "Matrix homeserver URL")
|
.option("--homeserver <url>", "Matrix homeserver URL")
|
||||||
.option("--user-id <id>", "Matrix user ID")
|
.option("--user-id <id>", "Matrix user ID")
|
||||||
.option("--access-token <token>", "Matrix access token")
|
.option("--access-token <token>", "Matrix access token")
|
||||||
|
|||||||
@ -39,6 +39,15 @@ 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;
|
||||||
|
oauthFromGog?: boolean;
|
||||||
|
gogAccount?: string;
|
||||||
|
gogClient?: string;
|
||||||
useEnv?: boolean;
|
useEnv?: boolean;
|
||||||
homeserver?: string;
|
homeserver?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@ -76,6 +85,15 @@ 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,
|
||||||
|
oauthFromGog: params.oauthFromGog,
|
||||||
|
gogAccount: params.gogAccount,
|
||||||
|
gogClient: params.gogClient,
|
||||||
useEnv: params.useEnv,
|
useEnv: params.useEnv,
|
||||||
homeserver: params.homeserver,
|
homeserver: params.homeserver,
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
|
|||||||
@ -36,6 +36,15 @@ 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;
|
||||||
|
oauthFromGog?: boolean;
|
||||||
|
gogAccount?: string;
|
||||||
|
gogClient?: string;
|
||||||
useEnv?: boolean;
|
useEnv?: boolean;
|
||||||
homeserver?: string;
|
homeserver?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@ -204,6 +213,15 @@ 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,
|
||||||
|
oauthFromGog: opts.oauthFromGog,
|
||||||
|
gogAccount: opts.gogAccount,
|
||||||
|
gogClient: opts.gogClient,
|
||||||
homeserver: opts.homeserver,
|
homeserver: opts.homeserver,
|
||||||
userId: opts.userId,
|
userId: opts.userId,
|
||||||
accessToken: opts.accessToken,
|
accessToken: opts.accessToken,
|
||||||
@ -247,6 +265,15 @@ 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,
|
||||||
|
oauthFromGog: opts.oauthFromGog,
|
||||||
|
gogAccount: opts.gogAccount,
|
||||||
|
gogClient: opts.gogClient,
|
||||||
homeserver: opts.homeserver,
|
homeserver: opts.homeserver,
|
||||||
userId: opts.userId,
|
userId: opts.userId,
|
||||||
accessToken: opts.accessToken,
|
accessToken: opts.accessToken,
|
||||||
|
|||||||
@ -60,6 +60,24 @@ 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;
|
||||||
|
/** Reuse gog OAuth credentials (user auth). */
|
||||||
|
oauthFromGog?: boolean;
|
||||||
|
/** gog account email to match refresh token (optional). */
|
||||||
|
gogAccount?: string;
|
||||||
|
/** gog client name to match credentials file (optional). */
|
||||||
|
gogClient?: string;
|
||||||
/** Webhook audience type (app-url or project-number). */
|
/** Webhook audience type (app-url or project-number). */
|
||||||
audienceType?: "app-url" | "project-number";
|
audienceType?: "app-url" | "project-number";
|
||||||
/** Audience value (app URL or project number). */
|
/** Audience value (app URL or project number). */
|
||||||
|
|||||||
@ -317,6 +317,15 @@ 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(),
|
||||||
|
oauthFromGog: z.boolean().optional(),
|
||||||
|
gogAccount: z.string().optional(),
|
||||||
|
gogClient: z.string().optional(),
|
||||||
audienceType: z.enum(["app-url", "project-number"]).optional(),
|
audienceType: z.enum(["app-url", "project-number"]).optional(),
|
||||||
audience: z.string().optional(),
|
audience: z.string().optional(),
|
||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user