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.
This commit is contained in:
root 2026-01-29 06:58:58 +00:00
parent 718bc3f9c8
commit 560d31b0ff
8 changed files with 632 additions and 13 deletions

View File

@ -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

View File

@ -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<string>
```
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}`
});
}
```

View File

@ -107,7 +107,11 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
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<ResolvedGoogleChatAccount> = {
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<ResolvedGoogleChatAccount> = {
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({

View File

@ -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") {

View File

@ -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<MoltbotConfig> {
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,
};
}

View File

@ -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<string> {
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<string> {
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;
}

View File

@ -15,6 +15,21 @@ export type GoogleChatDmConfig = {
allowFrom?: Array<string | number>;
};
/** 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<string, GoogleChatKnownSpace>;
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 = {

View File

@ -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();