From 560d31b0ffdb6a94b303b4d2b3ab61c6a0cdddc0 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 29 Jan 2026 06:58:58 +0000 Subject: [PATCH] feat(googlechat): add proactive messaging support Add space caching and auto-resolution for proactive Google Chat messages: - Auto-cache space IDs when users message the bot (knownSpaces) - Auto-resolve users/{id} to cached spaces for outbound messages - Fallback to findDirectMessage API when not cached - Add comprehensive documentation for proactive messaging patterns This enables cron jobs, skills, and agents to send proactive messages to users who have previously interacted with the bot. Fixes: Users can now send messages via 'users/{id}' target instead of needing to know the space ID beforehand. --- extensions/googlechat/CHANGES-SUMMARY.md | 115 ++++++++ extensions/googlechat/PROACTIVE-MESSAGING.md | 272 +++++++++++++++++++ extensions/googlechat/src/channel.ts | 18 +- extensions/googlechat/src/monitor.ts | 13 + extensions/googlechat/src/space-cache.ts | 130 +++++++++ extensions/googlechat/src/targets.ts | 66 ++++- src/config/types.googlechat.ts | 21 ++ src/config/zod-schema.providers-core.ts | 10 + 8 files changed, 632 insertions(+), 13 deletions(-) create mode 100644 extensions/googlechat/CHANGES-SUMMARY.md create mode 100644 extensions/googlechat/PROACTIVE-MESSAGING.md create mode 100644 extensions/googlechat/src/space-cache.ts diff --git a/extensions/googlechat/CHANGES-SUMMARY.md b/extensions/googlechat/CHANGES-SUMMARY.md new file mode 100644 index 000000000..be80a312a --- /dev/null +++ b/extensions/googlechat/CHANGES-SUMMARY.md @@ -0,0 +1,115 @@ +# Google Chat Proactive Messaging - Implementation Summary + +This document summarizes the changes made to enable proactive messaging in the Google Chat extension. + +## Files Modified + +### 1. `src/config/types.googlechat.ts` +**Added:** +- `GoogleChatKnownSpace` type - Structure for cached space entries +- `GoogleChatKnownSpaces` type - Map of user IDs to space info +- `knownSpaces` field to `GoogleChatAccountConfig` - Stores cached space mappings + +### 2. `src/config/zod-schema.providers-core.ts` +**Added:** +- `GoogleChatKnownSpaceSchema` - Zod validation schema for space cache entries +- `knownSpaces` field to `GoogleChatAccountSchema` - Config validation + +### 3. `extensions/googlechat/src/space-cache.ts` (NEW) +**Created:** +- `getKnownSpaces()` - Retrieve cached spaces for an account +- `getCachedSpaceForUser()` - Look up space by user ID +- `hasCachedSpace()` - Check if space is cached +- `buildSpaceCachePatch()` - Create config patch for new cache entries +- `extractSpaceInfoFromEvent()` - Extract space info from incoming messages + +### 4. `extensions/googlechat/src/targets.ts` +**Modified:** +- Updated `resolveGoogleChatOutboundSpace()` to accept config and options +- Added cache lookup before calling `findDirectMessage` API +- Added `ResolveSpaceOptions` type for `useCache` and `useFindDirectMessage` flags +- Improved error messages with actionable guidance + +### 5. `extensions/googlechat/src/monitor.ts` +**Modified:** +- Added import for `space-cache` utilities +- Added space caching logic in `processMessageWithPipeline()` +- Spaces are now cached automatically when users message the bot + +### 6. `extensions/googlechat/src/channel.ts` +**Modified:** +- Updated `sendText` to pass config to `resolveGoogleChatOutboundSpace()` +- Updated `sendMedia` to pass config to `resolveGoogleChatOutboundSpace()` +- Updated `notifyApproval` to pass config to `resolveGoogleChatOutboundSpace()` + +### 7. `extensions/googlechat/PROACTIVE-MESSAGING.md` (NEW) +**Created:** +- Comprehensive documentation for proactive messaging +- Usage examples for all 4 methods +- Troubleshooting guide +- Best practices + +## How It Works + +1. **Incoming Message** → `monitor.ts` extracts space info and caches it +2. **Outgoing Message** → `channel.ts` calls `resolveGoogleChatOutboundSpace()` +3. **Resolution** → `targets.ts` checks cache first, then API fallback +4. **Delivery** → Message sent via `api.ts` + +## Usage Examples + +### CLI +```bash +# Using user ID (auto-resolves to cached space) +moltbot message send --channel googlechat --to "users/123" --text "Hello!" + +# Using space ID (direct) +moltbot message send --channel googlechat --to "spaces/AAA" --text "Hello!" +``` + +### Config (Cron) +```json +{ + "cron": { + "jobs": [{ + "schedule": "0 9 * * *", + "text": "Morning!", + "target": "users/123", + "channel": "googlechat" + }] + } +} +``` + +### Config Structure +```json +{ + "channels": { + "googlechat": { + "knownSpaces": { + "users/123456": { + "spaceId": "spaces/AAAAxxxx", + "displayName": "John Doe", + "type": "DM", + "lastSeenAt": 1706515200000 + } + } + } + } +} +``` + +## Benefits + +1. **Automatic Caching** - No manual space ID management needed +2. **Multiple Fallbacks** - Cache → API → Error with guidance +3. **Backward Compatible** - Existing space ID targeting still works +4. **Well Documented** - Clear patterns for developers + +## Testing Recommendations + +1. Test incoming messages cache the space +2. Test proactive send using cached user ID +3. Test proactive send using space ID directly +4. Test error case when user hasn't messaged first +5. Test with multiple accounts diff --git a/extensions/googlechat/PROACTIVE-MESSAGING.md b/extensions/googlechat/PROACTIVE-MESSAGING.md new file mode 100644 index 000000000..14f0b2cf6 --- /dev/null +++ b/extensions/googlechat/PROACTIVE-MESSAGING.md @@ -0,0 +1,272 @@ +# Google Chat Proactive Messaging + +This document describes how to send proactive (initiated by the bot) messages in Google Chat, rather than only responding to incoming webhooks. + +## Overview + +Google Chat is unique among Moltbot channels because it operates purely via webhooks rather than persistent connections. This creates challenges for proactive messaging, but we've implemented several features to make it possible: + +1. **Space ID Persistence** - Auto-cache space IDs when users message the bot +2. **Auto-Resolve** - Automatically resolve `users/{id}` to cached spaces +3. **findDirectMessage Fallback** - Use Google Chat API to find DM spaces +4. **Manual Space Targeting** - Send directly to known space IDs + +## How It Works + +### Automatic Space Caching + +When a user messages your bot, Moltbot automatically caches the space mapping: + +```json +{ + "channels": { + "googlechat": { + "knownSpaces": { + "users/123456789": { + "spaceId": "spaces/AAAAxxxx", + "displayName": "John Doe", + "type": "DM", + "lastSeenAt": 1706515200000 + } + } + } + } +} +``` + +This cache persists across restarts and enables proactive messaging to users who have previously interacted with the bot. + +## Sending Proactive Messages + +### Method 1: Using User ID (Recommended) + +If the user has messaged your bot before, you can use their user ID: + +```bash +moltbot message send \ + --channel googlechat \ + --to "users/123456789" \ + --text "Hello! This is a proactive message." +``` + +Moltbot will: +1. Check the `knownSpaces` cache for the user +2. If found, send to the cached space +3. If not found, call `findDirectMessage` API +4. If still not found, show an error with instructions + +### Method 2: Using Space ID + +If you know the space ID (from a previous message or Google Chat UI): + +```bash +moltbot message send \ + --channel googlechat \ + --to "spaces/AAAAxxxx" \ + --text "Hello space!" +``` + +### Method 3: Via Cron/Heartbeat + +Schedule proactive messages in your config: + +```json +{ + "cron": { + "jobs": [ + { + "schedule": "0 9 * * *", + "text": "Good morning! Your daily reminder.", + "target": "users/123456789", + "channel": "googlechat" + } + ] + } +} +``` + +Or for spaces: + +```json +{ + "cron": { + "jobs": [ + { + "schedule": "0 9 * * MON", + "text": "Weekly standup time!", + "target": "spaces/AAAAxxxx", + "channel": "googlechat" + } + ] + } +} +``` + +### Method 4: From Skills/Agents + +Skills can send proactive messages using the message tool: + +```typescript +// In a skill +await message.send({ + channel: "googlechat", + target: "users/123456789", // or "spaces/AAAAxxxx" + text: "Proactive notification from skill!" +}); +``` + +## Target Formats + +Google Chat supports several target formats: + +| Format | Example | Description | +|--------|---------|-------------| +| User ID | `users/123456789` | Google Chat user resource name | +| Email | `users/user@example.com` | User's email address | +| Space ID | `spaces/AAAAxxxx` | Google Chat space resource name | +| With prefix | `googlechat:users/123` | Explicit channel prefix | + +## Troubleshooting + +### "No Google Chat DM found for users/xxx" + +This error means: +1. The user has never messaged your bot, OR +2. The space cache was lost, AND +3. The `findDirectMessage` API couldn't locate a DM + +**Solutions:** +- Ask the user to message your bot first +- Use the space ID directly if you know it +- Check your service account permissions + +### Service Account Permissions + +Your Google Chat service account needs these scopes: + +- `https://www.googleapis.com/auth/chat.bot` + +For `findDirectMessage` to work, the service account must be added to the Google Chat space or have domain-wide delegation (for Workspace admins). + +### Checking Cached Spaces + +To see cached spaces in your config: + +```bash +moltbot config get channels.googlechat.knownSpaces +``` + +### Clearing Space Cache + +If you need to clear the cache (e.g., spaces were renamed): + +```bash +moltbot config set channels.googlechat.knownSpaces '{}' +``` + +## Limitations + +1. **DMs require prior interaction** - You cannot initiate a DM with a user who has never messaged your bot +2. **Bot must be in space** - For group/room messages, the bot must be a member +3. **Service account limits** - Some API features require domain-wide delegation in Google Workspace + +## Comparison with Other Channels + +| Feature | WhatsApp | Telegram | Google Chat | +|---------|----------|----------|-------------| +| Initiate DMs | ✅ Yes | ✅ Yes | ❌ No* | +| Persistent connection | ✅ Yes | ✅ Yes | ❌ No | +| Requires webhook | ❌ No | Optional | ✅ Yes | +| Space caching | N/A | N/A | ✅ Auto | + +*Google Chat requires the user to message the bot first, or use `findDirectMessage` which may not always work + +## API Reference + +### `resolveGoogleChatOutboundSpace` + +```typescript +async function resolveGoogleChatOutboundSpace(params: { + account: ResolvedGoogleChatAccount; + target: string; + cfg?: MoltbotConfig; + useCache?: boolean; // default: true + useFindDirectMessage?: boolean; // default: true +}): Promise +``` + +Resolves a target (user ID or space ID) to a space ID for sending messages. + +### `getCachedSpaceForUser` + +```typescript +function getCachedSpaceForUser( + cfg: MoltbotConfig, + userId: string, + accountId?: string +): GoogleChatKnownSpace | undefined +``` + +Retrieves cached space info for a user. + +### Space Cache Schema + +```typescript +type GoogleChatKnownSpace = { + spaceId: string; // "spaces/AAAAxxxx" + displayName?: string; // User or space name + type?: "DM" | "ROOM"; // Space type + lastSeenAt?: number; // Timestamp +}; +``` + +## Best Practices + +1. **Let users message first** - Design flows where users initiate contact +2. **Cache proactively** - Store space IDs when users message, before you need them +3. **Handle errors gracefully** - Always wrap proactive sends in try-catch +4. **Use space IDs for critical messages** - More reliable than user ID resolution +5. **Document your flows** - Users should know why they're getting messages + +## Examples + +### Welcome Message After Pairing + +```typescript +// In pairing approval handler +await message.send({ + channel: "googlechat", + target: `users/${userId}`, + text: "You're now paired! I'll send you daily summaries at 9 AM." +}); +``` + +### Daily Digest + +```json +{ + "cron": { + "jobs": [{ + "schedule": "0 9 * * *", + "text": "📊 Your daily digest:\n- 3 new emails\n- 2 PRs awaiting review", + "target": "users/xxx", + "channel": "googlechat" + }] + } +} +``` + +### Error Notifications + +```typescript +// In a skill or agent +try { + await performTask(); +} catch (error) { + await message.send({ + channel: "googlechat", + target: `users/${adminUserId}`, + text: `⚠️ Task failed: ${error.message}` + }); +} +``` diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index eaa922767..f59cbf7b8 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -107,7 +107,11 @@ export const googlechatPlugin: ChannelPlugin = { if (account.credentialSource === "none") return; const user = normalizeGoogleChatTarget(id) ?? id; const target = isGoogleChatUserTarget(user) ? user : `users/${user}`; - const space = await resolveGoogleChatOutboundSpace({ account, target }); + const space = await resolveGoogleChatOutboundSpace({ + account, + target, + cfg: cfg as MoltbotConfig, + }); await sendGoogleChatMessage({ account, space, @@ -417,7 +421,11 @@ export const googlechatPlugin: ChannelPlugin = { cfg: cfg as MoltbotConfig, accountId, }); - const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + const space = await resolveGoogleChatOutboundSpace({ + account, + target: to, + cfg: cfg as MoltbotConfig, + }); const thread = (threadId ?? replyToId ?? undefined) as string | undefined; const result = await sendGoogleChatMessage({ account, @@ -439,7 +447,11 @@ export const googlechatPlugin: ChannelPlugin = { cfg: cfg as MoltbotConfig, accountId, }); - const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + const space = await resolveGoogleChatOutboundSpace({ + account, + target: to, + cfg: cfg as MoltbotConfig, + }); const thread = (threadId ?? replyToId ?? undefined) as string | undefined; const runtime = getGoogleChatRuntime(); const maxBytes = resolveChannelMediaMaxBytes({ diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 95874027b..b3ad36049 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -14,6 +14,7 @@ import { } from "./api.js"; import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js"; import { getGoogleChatRuntime } from "./runtime.js"; +import { extractSpaceInfoFromEvent, buildSpaceCachePatch } from "./space-cache.js"; import type { GoogleChatAnnotation, GoogleChatAttachment, @@ -394,6 +395,18 @@ async function processMessageWithPipeline(params: { const senderName = sender?.displayName ?? ""; const senderEmail = sender?.email ?? undefined; + // Cache space mapping for proactive messaging + if (senderId && spaceId) { + const spaceInfo = extractSpaceInfoFromEvent(event); + if (spaceInfo) { + const cachePatch = buildSpaceCachePatch(spaceInfo, account.accountId); + core.config.patchConfig(cachePatch).catch((err: Error) => { + logVerbose(core, runtime, `failed to cache space: ${err.message}`); + }); + logVerbose(core, runtime, `cached space ${spaceId} for user ${senderId}`); + } + } + const allowBots = account.config.allowBots === true; if (!allowBots) { if (sender?.type?.toUpperCase() === "BOT") { diff --git a/extensions/googlechat/src/space-cache.ts b/extensions/googlechat/src/space-cache.ts new file mode 100644 index 000000000..28b8cf2bf --- /dev/null +++ b/extensions/googlechat/src/space-cache.ts @@ -0,0 +1,130 @@ +import type { MoltbotConfig } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk"; + +import type { GoogleChatKnownSpace, GoogleChatKnownSpaces } from "../../../config/types.googlechat.js"; +import type { GoogleChatConfig } from "./types.config.js"; + +export type SpaceCacheEntry = { + userId: string; + spaceId: string; + displayName?: string; + type?: "DM" | "ROOM"; +}; + +/** + * Extract user ID from a Google Chat user resource name. + * e.g., "users/123456" -> "users/123456" + * e.g., "123456" -> "users/123456" + */ +function normalizeUserId(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.startsWith("users/")) return trimmed; + return `users/${trimmed}`; +} + +/** + * Get the knownSpaces map for a specific account. + */ +export function getKnownSpaces( + cfg: MoltbotConfig, + accountId: string = DEFAULT_ACCOUNT_ID, +): GoogleChatKnownSpaces { + const channel = cfg.channels?.["googlechat"] as GoogleChatConfig | undefined; + if (!channel) return {}; + + const accountConfig = accountId === DEFAULT_ACCOUNT_ID + ? channel + : channel.accounts?.[accountId]; + + return accountConfig?.knownSpaces ?? {}; +} + +/** + * Look up a cached space ID for a user. + * Returns undefined if not found. + */ +export function getCachedSpaceForUser( + cfg: MoltbotConfig, + userId: string, + accountId: string = DEFAULT_ACCOUNT_ID, +): GoogleChatKnownSpace | undefined { + const knownSpaces = getKnownSpaces(cfg, accountId); + const normalizedUserId = normalizeUserId(userId); + return knownSpaces[normalizedUserId]; +} + +/** + * Check if we have a cached space for a user. + */ +export function hasCachedSpace( + cfg: MoltbotConfig, + userId: string, + accountId: string = DEFAULT_ACCOUNT_ID, +): boolean { + return getCachedSpaceForUser(cfg, userId, accountId) !== undefined; +} + +/** + * Build config patch to cache a space mapping. + * Returns the patch object to merge into config. + */ +export function buildSpaceCachePatch( + entry: SpaceCacheEntry, + accountId: string = DEFAULT_ACCOUNT_ID, +): Partial { + const normalizedUserId = normalizeUserId(entry.userId); + const spaceEntry: GoogleChatKnownSpace = { + spaceId: entry.spaceId, + displayName: entry.displayName, + type: entry.type, + lastSeenAt: Date.now(), + }; + + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + channels: { + googlechat: { + knownSpaces: { + [normalizedUserId]: spaceEntry, + }, + }, + }, + }; + } + + return { + channels: { + googlechat: { + accounts: { + [accountId]: { + knownSpaces: { + [normalizedUserId]: spaceEntry, + }, + }, + }, + }, + }, + }; +} + +/** + * Extract space info from an incoming Google Chat event. + */ +export function extractSpaceInfoFromEvent(event: { + space?: { name?: string; displayName?: string; type?: string }; + user?: { name?: string }; +}): SpaceCacheEntry | null { + const spaceId = event.space?.name; + const userId = event.user?.name; + + if (!spaceId || !userId) return null; + + const spaceType = event.space?.type?.toUpperCase() === "DM" ? "DM" : "ROOM"; + + return { + userId, + spaceId, + displayName: event.space?.displayName, + type: spaceType, + }; +} diff --git a/extensions/googlechat/src/targets.ts b/extensions/googlechat/src/targets.ts index a294bf128..77bd4f948 100644 --- a/extensions/googlechat/src/targets.ts +++ b/extensions/googlechat/src/targets.ts @@ -1,5 +1,8 @@ +import type { MoltbotConfig } from "clawdbot/plugin-sdk"; + import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { findGoogleChatDirectMessage } from "./api.js"; +import { getCachedSpaceForUser } from "./space-cache.js"; export function normalizeGoogleChatTarget(raw?: string | null): string | undefined { const trimmed = raw?.trim(); @@ -31,25 +34,68 @@ function stripMessageSuffix(target: string): string { return target.slice(0, index); } -export async function resolveGoogleChatOutboundSpace(params: { - account: ResolvedGoogleChatAccount; - target: string; -}): Promise { - const normalized = normalizeGoogleChatTarget(params.target); +export type ResolveSpaceOptions = { + /** Enable cached space lookup (default: true) */ + useCache?: boolean; + /** Enable findDirectMessage API fallback (default: true) */ + useFindDirectMessage?: boolean; +}; + +/** + * Resolve a Google Chat target to a space ID. + * + * Resolution order: + * 1. If target is already a space ID, return it + * 2. Check knownSpaces cache for user + * 3. Call findDirectMessage API (if enabled) + * 4. Throw error if no space found + */ +export async function resolveGoogleChatOutboundSpace( + params: { + account: ResolvedGoogleChatAccount; + target: string; + cfg?: MoltbotConfig; + } & ResolveSpaceOptions, +): Promise { + const { account, target, cfg, useCache = true, useFindDirectMessage = true } = params; + + const normalized = normalizeGoogleChatTarget(target); if (!normalized) { throw new Error("Missing Google Chat target."); } + const base = stripMessageSuffix(normalized); + + // 1. Already a space target if (isGoogleChatSpaceTarget(base)) return base; - if (isGoogleChatUserTarget(base)) { + + // 2. User target - try cache first + if (isGoogleChatUserTarget(base) && useCache && cfg) { + const cached = getCachedSpaceForUser(cfg, base, account.accountId); + if (cached?.spaceId) { + return cached.spaceId; + } + } + + // 3. User target - try findDirectMessage API + if (isGoogleChatUserTarget(base) && useFindDirectMessage) { const dm = await findGoogleChatDirectMessage({ - account: params.account, + account, userName: base, }); - if (!dm?.name) { - throw new Error(`No Google Chat DM found for ${base}`); + if (dm?.name) { + return dm.name; } - return dm.name; } + + // 4. Failed to resolve + if (isGoogleChatUserTarget(base)) { + throw new Error( + `No Google Chat DM found for ${base}. ` + + `The user must message the bot first, or you can use: ` + + `moltbot message send --channel googlechat --to "spaces/XXX" --text "..."` + ); + } + return base; } diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts index 5fceff49e..5aa2cdbe1 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -15,6 +15,21 @@ export type GoogleChatDmConfig = { allowFrom?: Array; }; +/** Cached space mapping for proactive messaging */ +export type GoogleChatKnownSpace = { + /** Space ID (e.g., "spaces/AAAA...") */ + spaceId: string; + /** Space display name */ + displayName?: string; + /** Space type: DM or ROOM */ + type?: "DM" | "ROOM"; + /** When this mapping was last updated */ + lastSeenAt?: number; +}; + +/** Known spaces cache for proactive messaging */ +export type GoogleChatKnownSpaces = Record; + export type GoogleChatGroupConfig = { /** If false, disable the bot in this space. (Alias for allow: false.) */ enabled?: boolean; @@ -98,6 +113,12 @@ export type GoogleChatAccountConfig = { * If configured, falls back to message mode with a warning. */ typingIndicator?: "none" | "message" | "reaction"; + /** + * Cached space mappings for proactive messaging. + * Keys are user IDs (e.g., "users/123..."), values contain the space ID. + * Auto-populated when users message the bot. + */ + knownSpaces?: GoogleChatKnownSpaces; }; export type GoogleChatConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ed7dda22a..8a4a63216 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -310,6 +310,15 @@ export const GoogleChatGroupSchema = z }) .strict(); +const GoogleChatKnownSpaceSchema = z + .object({ + spaceId: z.string(), + displayName: z.string().optional(), + type: z.enum(["DM", "ROOM"]).optional(), + lastSeenAt: z.number().optional(), + }) + .strict(); + export const GoogleChatAccountSchema = z .object({ name: z.string().optional(), @@ -345,6 +354,7 @@ export const GoogleChatAccountSchema = z .optional(), dm: GoogleChatDmSchema.optional(), typingIndicator: z.enum(["none", "message", "reaction"]).optional(), + knownSpaces: z.record(z.string(), GoogleChatKnownSpaceSchema.optional()).optional(), }) .strict();