feat(googlechat): support Google Workspace Add-on event format
This commit is contained in:
committed by
Peter Steinberger
parent
0f6e39b9e8
commit
5991bed32e
@@ -162,9 +162,42 @@ Notes:
|
|||||||
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
|
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
### 405 Method Not Allowed
|
||||||
|
If Google Cloud Logs Explorer shows errors like:
|
||||||
|
```
|
||||||
|
status code: 405, reason phrase: HTTP error response: HTTP/1.1 405 Method Not Allowed
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the webhook handler isn't registered. Common causes:
|
||||||
|
1. **Channel not configured**: The `channels.googlechat` section is missing from your config. Verify with:
|
||||||
|
```bash
|
||||||
|
clawdbot config get channels.googlechat
|
||||||
|
```
|
||||||
|
If it returns "Config path not found", add the configuration (see [Config highlights](#config-highlights)).
|
||||||
|
|
||||||
|
2. **Plugin not enabled**: Check plugin status:
|
||||||
|
```bash
|
||||||
|
clawdbot plugins list | grep googlechat
|
||||||
|
```
|
||||||
|
If it shows "disabled", add `plugins.entries.googlechat.enabled: true` to your config.
|
||||||
|
|
||||||
|
3. **Gateway not restarted**: After adding config, restart the gateway:
|
||||||
|
```bash
|
||||||
|
clawdbot gateway restart
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the channel is running:
|
||||||
|
```bash
|
||||||
|
clawdbot channels status
|
||||||
|
# Should show: Google Chat default: enabled, configured, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other issues
|
||||||
- Check `clawdbot channels status --probe` for auth errors or missing audience config.
|
- Check `clawdbot channels status --probe` for auth errors or missing audience config.
|
||||||
- If no messages arrive, confirm the Chat app’s webhook URL + event subscriptions.
|
- If no messages arrive, confirm the Chat app's webhook URL + event subscriptions.
|
||||||
- If mention gating blocks replies, set `botUser` to the app’s user resource name and verify `requireMention`.
|
- If mention gating blocks replies, set `botUser` to the app's user resource name and verify `requireMention`.
|
||||||
|
- Use `clawdbot logs --follow` while sending a test message to see if requests reach the gateway.
|
||||||
|
|
||||||
Related docs:
|
Related docs:
|
||||||
- [Gateway configuration](/gateway/configuration)
|
- [Gateway configuration](/gateway/configuration)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { ResolvedGoogleChatAccount } from "./accounts.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";
|
||||||
|
// Google Workspace Add-ons use a different service account pattern
|
||||||
|
const ADDON_ISSUER_PATTERN = /^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$/;
|
||||||
const CHAT_CERTS_URL =
|
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";
|
||||||
|
|
||||||
@@ -87,8 +89,9 @@ export async function verifyGoogleChatRequest(params: {
|
|||||||
audience,
|
audience,
|
||||||
});
|
});
|
||||||
const payload = ticket.getPayload();
|
const payload = ticket.getPayload();
|
||||||
const ok = Boolean(payload?.email_verified) && payload?.email === CHAT_ISSUER;
|
const email = payload?.email ?? "";
|
||||||
return ok ? { ok: true } : { ok: false, reason: "invalid issuer" };
|
const ok = payload?.email_verified && (email === CHAT_ISSUER || ADDON_ISSUER_PATTERN.test(email));
|
||||||
|
return ok ? { ok: true } : { ok: false, reason: `invalid issuer: ${email}` };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
|
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js";
|
import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js";
|
||||||
import { getGoogleChatRuntime } from "./runtime.js";
|
import { getGoogleChatRuntime } from "./runtime.js";
|
||||||
import type { GoogleChatAnnotation, GoogleChatAttachment, GoogleChatEvent } from "./types.js";
|
import type {
|
||||||
|
GoogleChatAnnotation,
|
||||||
|
GoogleChatAttachment,
|
||||||
|
GoogleChatEvent,
|
||||||
|
GoogleChatSpace,
|
||||||
|
GoogleChatMessage,
|
||||||
|
GoogleChatUser,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
export type GoogleChatRuntimeEnv = {
|
export type GoogleChatRuntimeEnv = {
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
@@ -168,44 +175,76 @@ export async function handleGoogleChatWebhookRequest(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = body.value;
|
let raw = body.value;
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end("invalid payload");
|
res.end("invalid payload");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = (raw as { type?: string; eventType?: string }).type ??
|
// Transform Google Workspace Add-on format to standard Chat API format
|
||||||
(raw as { type?: string; eventType?: string }).eventType;
|
const rawObj = raw as {
|
||||||
|
commonEventObject?: { hostApp?: string };
|
||||||
|
chat?: {
|
||||||
|
messagePayload?: { space?: GoogleChatSpace; message?: GoogleChatMessage };
|
||||||
|
user?: GoogleChatUser;
|
||||||
|
eventTime?: string;
|
||||||
|
};
|
||||||
|
authorizationEventObject?: { systemIdToken?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
|
||||||
|
const chat = rawObj.chat;
|
||||||
|
const messagePayload = chat.messagePayload;
|
||||||
|
raw = {
|
||||||
|
type: "MESSAGE",
|
||||||
|
space: messagePayload?.space,
|
||||||
|
message: messagePayload?.message,
|
||||||
|
user: chat.user,
|
||||||
|
eventTime: chat.eventTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
// For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken
|
||||||
|
const systemIdToken = rawObj.authorizationEventObject?.systemIdToken;
|
||||||
|
if (!bearer && systemIdToken) {
|
||||||
|
Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = raw as GoogleChatEvent;
|
||||||
|
const eventType = event.type ?? (raw as { eventType?: string }).eventType;
|
||||||
if (typeof eventType !== "string") {
|
if (typeof eventType !== "string") {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end("invalid payload");
|
res.end("invalid payload");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawObj = raw as Record<string, unknown>;
|
if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
|
||||||
if (!rawObj.space || typeof rawObj.space !== "object" || Array.isArray(rawObj.space)) {
|
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end("invalid payload");
|
res.end("invalid payload");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventType === "MESSAGE") {
|
if (eventType === "MESSAGE") {
|
||||||
if (!rawObj.message || typeof rawObj.message !== "object" || Array.isArray(rawObj.message)) {
|
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end("invalid payload");
|
res.end("invalid payload");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = raw as GoogleChatEvent;
|
// Re-extract bearer in case it was updated from Add-on format
|
||||||
|
const authHeaderNow = String(req.headers.authorization ?? "");
|
||||||
|
const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ")
|
||||||
|
? authHeaderNow.slice("bearer ".length)
|
||||||
|
: bearer;
|
||||||
|
|
||||||
let selected: WebhookTarget | undefined;
|
let selected: WebhookTarget | undefined;
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const audienceType = target.audienceType;
|
const audienceType = target.audienceType;
|
||||||
const audience = target.audience;
|
const audience = target.audience;
|
||||||
const verification = await verifyGoogleChatRequest({
|
const verification = await verifyGoogleChatRequest({
|
||||||
bearer,
|
bearer: effectiveBearer,
|
||||||
audienceType,
|
audienceType,
|
||||||
audience,
|
audience,
|
||||||
});
|
});
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -300,7 +300,9 @@ importers:
|
|||||||
|
|
||||||
extensions/google-antigravity-auth: {}
|
extensions/google-antigravity-auth: {}
|
||||||
|
|
||||||
extensions/google-chat:
|
extensions/google-gemini-cli-auth: {}
|
||||||
|
|
||||||
|
extensions/googlechat:
|
||||||
dependencies:
|
dependencies:
|
||||||
clawdbot:
|
clawdbot:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -309,8 +311,6 @@ importers:
|
|||||||
specifier: ^10.5.0
|
specifier: ^10.5.0
|
||||||
version: 10.5.0
|
version: 10.5.0
|
||||||
|
|
||||||
extensions/google-gemini-cli-auth: {}
|
|
||||||
|
|
||||||
extensions/imessage: {}
|
extensions/imessage: {}
|
||||||
|
|
||||||
extensions/llm-task: {}
|
extensions/llm-task: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user