@mercury-fw/channel-google-chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @mercury-fw/channel-google-chat
2
+
3
+ Puts a [Mercury](https://github.com/lucabro81/mercury-fw) agent on Google Chat as a registered Chat app: it receives messages through a Pub/Sub subscription and replies through the Chat API, with status cards while it works and a button for the actions that need confirmation.
4
+
5
+ ```bash
6
+ bun add @mercury-fw/channel-google-chat
7
+ ```
8
+
9
+ ```ts
10
+ import { googleChatChannel } from "@mercury-fw/channel-google-chat";
11
+
12
+ channels: [googleChatChannel],
13
+ ```
14
+
15
+ | Variable | |
16
+ |---|---|
17
+ | `GOOGLE_CHAT_PUBSUB_SUBSCRIPTION` | `projects/<project>/subscriptions/<subscription>` the Chat app's events arrive on. Empty leaves the channel inert. |
18
+ | `GOOGLE_CHAT_APP_CLIENT_EMAIL` | The service account the app authenticates as. |
19
+ | `GOOGLE_CHAT_APP_PRIVATE_KEY` | That service account's private key. |
20
+
21
+ Each instance needs a Chat app of its own (its own Google Cloud project, topic, subscription and service account): two instances on one subscription either both answer or split a conversation between them. The [reference instance's README](https://github.com/lucabro81/mercury-fw/tree/main/apps/mercury#setting-up-the-chat-apps-google-cloud-project) has the `gcloud` commands, and the one step Cloud Console only does by hand.
22
+
23
+ MIT
@@ -0,0 +1,71 @@
1
+ export type ServiceAccountCredentials = {
2
+ clientEmail: string;
3
+ privateKey: string;
4
+ };
5
+ /** Builds and signs the JWT assertion for the OAuth2 JWT-bearer grant. Pure function, no I/O — testable without a real key. */
6
+ export declare function buildSignedAssertion(creds: ServiceAccountCredentials, scope: string, nowSeconds: number): string;
7
+ export type FetchFn = typeof fetch;
8
+ export type TokenSource = {
9
+ getToken(): Promise<string>;
10
+ };
11
+ /** A lazily-refreshing token source: mints once, reuses until close to expiry, re-mints after. */
12
+ export declare function createTokenSource(creds: ServiceAccountCredentials, deps?: {
13
+ fetchFn?: FetchFn;
14
+ nowSeconds?: () => number;
15
+ }): TokenSource;
16
+ /**
17
+ * Finds or creates a DIRECT_MESSAGE space with `userId` (`users/<id>`).
18
+ * Checks `spaces.findDirectMessage` first and reuses an existing DM if one
19
+ * exists; falls back to `spaces.setup` (creates a space **and** adds the
20
+ * given member in one call — the closest single-call equivalent of the
21
+ * retired `gchat-cli`'s `spaces create --user`, which the CLI's own docs
22
+ * described as wrapping `spaces.setup`) when none is found.
23
+ */
24
+ export declare function getOrCreateDmSpace(userId: string, deps: {
25
+ tokenSource: TokenSource;
26
+ fetchFn?: FetchFn;
27
+ }): Promise<{
28
+ name: string;
29
+ }>;
30
+ /** Sends a plain-text message to `space`. Returns the created message's `name` (used for loop-prevention, same as the retired impersonation-era transport). */
31
+ export declare function sendMessage(space: string, text: string, deps: {
32
+ tokenSource: TokenSource;
33
+ fetchFn?: FetchFn;
34
+ }): Promise<{
35
+ name: string;
36
+ }>;
37
+ /**
38
+ * A single Cards v2 message — one call, `cardsV2` is an array because the
39
+ * API supports multiple cards per message, but every caller here sends
40
+ * exactly one. A section's `header` (distinct from the card-level
41
+ * `header.title`) plus `collapsible`/`uncollapsibleWidgetsCount` is Google
42
+ * Chat's own native accordion primitive: `header` stays visible regardless
43
+ * of collapse state, and widgets beyond `uncollapsibleWidgetsCount` fold
44
+ * behind a "Show more" toggle.
45
+ */
46
+ export type ChatCardSection = {
47
+ widgets: unknown[];
48
+ header?: string;
49
+ collapsible?: boolean;
50
+ uncollapsibleWidgetsCount?: number;
51
+ };
52
+ export type ChatCard = {
53
+ header?: {
54
+ title: string;
55
+ };
56
+ sections: ChatCardSection[];
57
+ };
58
+ /** Posts `card` as a Cards v2 message to `space`. Returns the created message's `name`, same shape as `sendMessage`. */
59
+ export declare function sendCard(space: string, card: ChatCard, deps: {
60
+ tokenSource: TokenSource;
61
+ fetchFn?: FetchFn;
62
+ }): Promise<{
63
+ name: string;
64
+ }>;
65
+ /** Edits an already-sent card message's `cardsV2` in place (PATCH, `updateMask=cardsV2`) — used to patch a tool-call status card from loading to its final outcome without sending a new message. */
66
+ export declare function updateCard(name: string, card: ChatCard, deps: {
67
+ tokenSource: TokenSource;
68
+ fetchFn?: FetchFn;
69
+ }): Promise<{
70
+ name: string;
71
+ }>;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Splits a complete final answer into more than one Google Chat message
3
+ * only when it would exceed the API's own message-size limit — a purely
4
+ * technical safety net, not a readability choice (see
5
+ * `google-chat-streamer.ts`, the only caller). Google Chat rejects a
6
+ * message over 32,000 bytes ("the maximum message size... is 32,000
7
+ * bytes... your Chat app must send multiple messages instead" —
8
+ * https://developers.google.com/workspace/chat/create-messages). A local
9
+ * model's answer coming anywhere close to that is effectively never
10
+ * observed in practice, but the cut stays in place as a correctness net
11
+ * regardless. Caller: `google-chat-provider.ts`.
12
+ */
13
+ export declare const MAX_CHAT_MESSAGE_CHARS = 30000;
14
+ /**
15
+ * Splits `text` at the last whitespace at-or-before `MAX_CHAT_MESSAGE_CHARS`,
16
+ * repeated as needed — never splits a single word, even if that means one
17
+ * piece exceeds the cap (a single unbroken token longer than the cap is
18
+ * left intact, the one documented case where the cap can be exceeded).
19
+ * Returns `[]` for empty/whitespace-only input.
20
+ */
21
+ export declare function splitForSendLimit(text: string): string[];
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The registered Chat app's `Provider` — replaces the retired
3
+ * impersonation-based channel (`google-chat-client.ts`/`google-chat-events.ts`/
4
+ * `google-chat-streamer.ts`/`google-chat-message-buffer.ts`, all deleted)
5
+ * wholesale, not as an adapter over it. Built directly against the
6
+ * chat-app model: `chat.bot`-scoped service-account auth
7
+ * (`google-chat-app-client.ts`), a single Pub/Sub subscription for the
8
+ * whole app (no more per-space Workspace Events subscriptions — a real
9
+ * simplification the old impersonation path needed and this one doesn't),
10
+ * and a real per-app bot identity.
11
+ *
12
+ * Session key is `space:sender` — same as the retired channel's own key
13
+ * (see `deriveSessionKey` for why a thread-keyed variant, tried initially,
14
+ * broke conversation continuity in DMs).
15
+ *
16
+ * The `[Da: X]` sender marker no longer needs a People-API round trip: a
17
+ * registered app's `MESSAGE` event already carries `sender.displayName`
18
+ * directly (confirmed live against a real captured event earlier this
19
+ * session) — `resolveSenderName`/`getUser`/the `resolved-name.md` wiki
20
+ * cache are not used by this provider at all.
21
+ */
22
+ import { sendMessage, sendCard, updateCard, getOrCreateDmSpace, type ServiceAccountCredentials, type TokenSource } from "./google-chat-app-client.ts";
23
+ import { type PubSubSubscription } from "./google-chat-pubsub-stream.ts";
24
+ import { type Provider } from "@mercury-fw/channel-types";
25
+ /**
26
+ * Composite session key: space + sender. Deliberately does NOT include
27
+ * `thread` — confirmed live (two consecutive messages in the same DM
28
+ * conversation produced two different `thread.name` values) that Google
29
+ * Chat assigns a fresh thread to every top-level message in a DM (the DM
30
+ * UI has no way to reply in-thread at all), so a thread-keyed session was
31
+ * a brand new, empty history on every single message. Matches the retired
32
+ * impersonation-based channel's own key exactly. Revisit only once Mercury
33
+ * actually participates in threaded group spaces with a demonstrated need
34
+ * for parallel per-thread conversations — not speculatively.
35
+ */
36
+ export declare function deriveSessionKey(space: string, sender: string): string;
37
+ export type ParsedMessageEvent = {
38
+ kind: "message";
39
+ text: string;
40
+ messageName: string;
41
+ space: string;
42
+ sender: string;
43
+ senderDisplayName: string | undefined;
44
+ /**
45
+ * True only when `space.type` is exactly `"DM"` (the Chat API's shape
46
+ * for a private 1:1 space) — anything else, including a missing field,
47
+ * stays `false` on purpose: a DM is unambiguously addressed to Mercury,
48
+ * everything else keeps the cautious multi-user default. Confirmed
49
+ * live against a real DM event this session.
50
+ */
51
+ isDirectMessage: boolean;
52
+ };
53
+ export type ParsedCardClickEvent = {
54
+ kind: "card-click";
55
+ space: string;
56
+ sender: string;
57
+ parameters: Record<string, string>;
58
+ };
59
+ /** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
60
+ export declare function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null;
61
+ export type CardClickHandler = (params: Record<string, string>, space: string, sender: string) => Promise<void>;
62
+ export type GoogleChatProviderDeps = {
63
+ credentials: ServiceAccountCredentials;
64
+ subscription: string;
65
+ /**
66
+ * Resolves a confirmation token against the core's shared store, injected by
67
+ * the channel loader (`ChannelRuntimeContext.confirm`). Returns the reply to
68
+ * send, or `null` when the input wasn't token-shaped. This is the whole of
69
+ * the channel's contact with confirmation — the store, vault and note-writer
70
+ * stay in the core.
71
+ */
72
+ confirm: (token: string, sessionKey: string, userId: string) => Promise<string | null>;
73
+ /**
74
+ * Handles a `CARD_CLICKED` event's action parameters. Defaults to resolving
75
+ * the confirm button's token through `deps.confirm` (the same path a bare
76
+ * token typed on the terminal uses) — override only to handle a different
77
+ * card's click shape (e.g. a future `notify-user` disambiguation token), not
78
+ * to change how confirmation itself resolves.
79
+ */
80
+ onCardClick?: CardClickHandler;
81
+ /** Test seams — default to the real client functions bound with a token source built from `credentials`. */
82
+ tokenSourceFn?: (creds: ServiceAccountCredentials) => TokenSource;
83
+ sendMessageFn?: typeof sendMessage;
84
+ sendCardFn?: typeof sendCard;
85
+ updateCardFn?: typeof updateCard;
86
+ getOrCreateDmSpaceFn?: typeof getOrCreateDmSpace;
87
+ /** Test seam — defaults to `openSubscription` (real StreamingPull); tests inject a fake `PubSubSubscription`. */
88
+ subscriptionFn?: (creds: ServiceAccountCredentials, subscription: string) => PubSubSubscription;
89
+ log?: (msg: string) => void;
90
+ };
91
+ export type GoogleChatProvider = Provider & {
92
+ stop(): Promise<void>;
93
+ };
94
+ /** Builds the registered Google Chat app's `Provider`. */
95
+ export declare function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleChatProvider;
@@ -0,0 +1,27 @@
1
+ import type { ServiceAccountCredentials } from "./google-chat-app-client.ts";
2
+ /** One incoming Pub/Sub message. `data` is already the raw message body — the SDK handles the wire-level base64 transport itself. */
3
+ export type StreamMessage = {
4
+ data: Buffer;
5
+ ack: () => void;
6
+ nack: () => void;
7
+ };
8
+ /** The subset of `@google-cloud/pubsub`'s `Subscription` this app actually uses — narrowed so a fake can satisfy it in tests without depending on the real SDK's types. */
9
+ export type PubSubSubscription = {
10
+ on(event: "message", listener: (message: StreamMessage) => void): void;
11
+ on(event: "error", listener: (err: Error) => void): void;
12
+ close(): Promise<void>;
13
+ };
14
+ /** Splits `"projects/<id>/subscriptions/<name>"` into its two parts — thrown separately so a malformed env var fails fast and clearly, not deep inside the SDK's own error handling. */
15
+ export declare function parseSubscriptionName(resourceName: string): {
16
+ projectId: string;
17
+ subscriptionId: string;
18
+ };
19
+ /**
20
+ * Opens a StreamingPull subscription. Real gRPC — exercised by the Phase 0
21
+ * spike and live verification, not by unit tests (`google-chat-provider.ts`
22
+ * injects a fake `PubSubSubscription` via its own `subscriptionFn` seam for
23
+ * those). Credentials are passed directly (`client_email`/`private_key`),
24
+ * same service-account values already used for the Chat API's own
25
+ * JWT-bearer flow — no key file on disk, no second credential to manage.
26
+ */
27
+ export declare function openSubscription(credentials: ServiceAccountCredentials, resourceName: string): PubSubSubscription;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The Google Chat channel plugin: the `ChannelPlugin` the core's channel loader
3
+ * consumes. `build()` reads this channel's own config from `ctx.env` and
4
+ * constructs the provider; it returns `undefined` when no
5
+ * `GOOGLE_CHAT_PUBSUB_SUBSCRIPTION` is set (the instance isn't running Google
6
+ * Chat — present but inert). A subscription set with the app credentials
7
+ * missing is a misconfiguration: `build()` throws and the loader skips this
8
+ * channel fail-soft, leaving the rest of Mercury up.
9
+ */
10
+ import { type ChannelPlugin } from "@mercury-fw/channel-types";
11
+ export declare const googleChatChannel: ChannelPlugin;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The registered Chat app's own transport — Chat REST API + Pub/Sub pull,
3
+ * called directly over HTTPS from this process. Channel transport
4
+ * (reading/sending the messages that carry a conversation) is never something
5
+ * the model drives, unlike a tool such as `notify-user.ts`, which it does invoke.
6
+ *
7
+ * Auth: a service-account JWT-bearer flow (RFC 7523), signed with Node's
8
+ * built-in `crypto` — no new OAuth/Google API client dependency needed for
9
+ * this. Scoped to `chat.bot` only — event delivery now goes through
10
+ * `google-chat-pubsub-stream.ts` (`@google-cloud/pubsub`'s StreamingPull),
11
+ * which mints its own token internally from the same credentials, not
12
+ * through this token source. Re-minted lazily once it's within a minute of
13
+ * expiring. Verified live against the real APIs earlier in this project's
14
+ * history (a throwaway service account, JWT-bearer flow, `chat.bot` scope,
15
+ * `spaces.messages.create` → HTTP 200, delivered as the app's own `BOT`
16
+ * identity) before this file was written — this is that same pattern,
17
+ * generalized and made persistent instead of a one-off script.
18
+ */
19
+ import { createSign } from "node:crypto";
20
+
21
+ export type ServiceAccountCredentials = { clientEmail: string; privateKey: string };
22
+
23
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
24
+ const CHAT_API_BASE = "https://chat.googleapis.com/v1";
25
+ const SCOPES = "https://www.googleapis.com/auth/chat.bot";
26
+ /** Re-mint this long before real expiry — a token that expires mid-request is worse than one wasted early. */
27
+ const REFRESH_MARGIN_SECONDS = 60;
28
+
29
+ function base64url(input: Buffer | string): string {
30
+ return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
31
+ }
32
+
33
+ /** Builds and signs the JWT assertion for the OAuth2 JWT-bearer grant. Pure function, no I/O — testable without a real key. */
34
+ export function buildSignedAssertion(creds: ServiceAccountCredentials, scope: string, nowSeconds: number): string {
35
+ const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
36
+ const claims = base64url(
37
+ JSON.stringify({
38
+ iss: creds.clientEmail,
39
+ scope,
40
+ aud: TOKEN_URL,
41
+ exp: nowSeconds + 3600,
42
+ iat: nowSeconds,
43
+ }),
44
+ );
45
+ const unsigned = `${header}.${claims}`;
46
+ const signer = createSign("RSA-SHA256");
47
+ signer.update(unsigned);
48
+ signer.end();
49
+ const signature = base64url(signer.sign(creds.privateKey));
50
+ return `${unsigned}.${signature}`;
51
+ }
52
+
53
+ export type FetchFn = typeof fetch;
54
+
55
+ /** Exchanges a signed JWT assertion for an access token. Throws with the response body on failure — no fallback identity to authenticate as instead. */
56
+ async function exchangeAssertionForToken(
57
+ assertion: string,
58
+ fetchFn: FetchFn,
59
+ ): Promise<{ accessToken: string; expiresInSeconds: number }> {
60
+ const response = await fetchFn(TOKEN_URL, {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
63
+ body: new URLSearchParams({
64
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
65
+ assertion,
66
+ }).toString(),
67
+ });
68
+ if (!response.ok) {
69
+ throw new Error(`token exchange failed: HTTP ${response.status} ${await response.text()}`);
70
+ }
71
+ const data = (await response.json()) as { access_token: string; expires_in: number };
72
+ return { accessToken: data.access_token, expiresInSeconds: data.expires_in };
73
+ }
74
+
75
+ export type TokenSource = { getToken(): Promise<string> };
76
+
77
+ /** A lazily-refreshing token source: mints once, reuses until close to expiry, re-mints after. */
78
+ export function createTokenSource(
79
+ creds: ServiceAccountCredentials,
80
+ deps: { fetchFn?: FetchFn; nowSeconds?: () => number } = {},
81
+ ): TokenSource {
82
+ const fetchFn = deps.fetchFn ?? fetch;
83
+ const nowSeconds = deps.nowSeconds ?? (() => Math.floor(Date.now() / 1000));
84
+ let cached: { accessToken: string; expiresAt: number } | null = null;
85
+
86
+ return {
87
+ async getToken(): Promise<string> {
88
+ const now = nowSeconds();
89
+ if (cached && cached.expiresAt - REFRESH_MARGIN_SECONDS > now) {
90
+ return cached.accessToken;
91
+ }
92
+ const assertion = buildSignedAssertion(creds, SCOPES, now);
93
+ const { accessToken, expiresInSeconds } = await exchangeAssertionForToken(assertion, fetchFn);
94
+ cached = { accessToken, expiresAt: now + expiresInSeconds };
95
+ return accessToken;
96
+ },
97
+ };
98
+ }
99
+
100
+ /** Throws with the response body on a non-2xx result — there's no fallback the caller can take instead of knowing the real call failed. */
101
+ async function callChatApi(
102
+ path: string,
103
+ init: { method: string; body?: unknown },
104
+ deps: { tokenSource: TokenSource; fetchFn?: FetchFn },
105
+ ): Promise<unknown> {
106
+ const fetchFn = deps.fetchFn ?? fetch;
107
+ const token = await deps.tokenSource.getToken();
108
+ const response = await fetchFn(`${CHAT_API_BASE}/${path}`, {
109
+ method: init.method,
110
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
111
+ body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
112
+ });
113
+ if (!response.ok) {
114
+ throw new Error(`Chat API ${init.method} ${path} failed: HTTP ${response.status} ${await response.text()}`);
115
+ }
116
+ return response.json();
117
+ }
118
+
119
+ /**
120
+ * Finds or creates a DIRECT_MESSAGE space with `userId` (`users/<id>`).
121
+ * Checks `spaces.findDirectMessage` first and reuses an existing DM if one
122
+ * exists; falls back to `spaces.setup` (creates a space **and** adds the
123
+ * given member in one call — the closest single-call equivalent of the
124
+ * retired `gchat-cli`'s `spaces create --user`, which the CLI's own docs
125
+ * described as wrapping `spaces.setup`) when none is found.
126
+ */
127
+ export async function getOrCreateDmSpace(
128
+ userId: string,
129
+ deps: { tokenSource: TokenSource; fetchFn?: FetchFn },
130
+ ): Promise<{ name: string }> {
131
+ const found = (await callChatApi(
132
+ `spaces:findDirectMessage?name=${encodeURIComponent(userId)}`,
133
+ { method: "GET" },
134
+ deps,
135
+ ).catch(() => null)) as { name?: string } | null;
136
+ if (found?.name) {
137
+ return { name: found.name };
138
+ }
139
+ const created = (await callChatApi(
140
+ "spaces:setup",
141
+ { method: "POST", body: { space: { spaceType: "DIRECT_MESSAGE" }, memberships: [{ member: { name: userId, type: "HUMAN" } }] } },
142
+ deps,
143
+ )) as { name: string };
144
+ return { name: created.name };
145
+ }
146
+
147
+ /** Sends a plain-text message to `space`. Returns the created message's `name` (used for loop-prevention, same as the retired impersonation-era transport). */
148
+ export async function sendMessage(
149
+ space: string,
150
+ text: string,
151
+ deps: { tokenSource: TokenSource; fetchFn?: FetchFn },
152
+ ): Promise<{ name: string }> {
153
+ const data = (await callChatApi(`${space}/messages`, { method: "POST", body: { text } }, deps)) as { name: string };
154
+ return { name: data.name };
155
+ }
156
+
157
+ /**
158
+ * A single Cards v2 message — one call, `cardsV2` is an array because the
159
+ * API supports multiple cards per message, but every caller here sends
160
+ * exactly one. A section's `header` (distinct from the card-level
161
+ * `header.title`) plus `collapsible`/`uncollapsibleWidgetsCount` is Google
162
+ * Chat's own native accordion primitive: `header` stays visible regardless
163
+ * of collapse state, and widgets beyond `uncollapsibleWidgetsCount` fold
164
+ * behind a "Show more" toggle.
165
+ */
166
+ export type ChatCardSection = {
167
+ widgets: unknown[];
168
+ header?: string;
169
+ collapsible?: boolean;
170
+ uncollapsibleWidgetsCount?: number;
171
+ };
172
+ export type ChatCard = { header?: { title: string }; sections: ChatCardSection[] };
173
+
174
+ /** Posts `card` as a Cards v2 message to `space`. Returns the created message's `name`, same shape as `sendMessage`. */
175
+ export async function sendCard(
176
+ space: string,
177
+ card: ChatCard,
178
+ deps: { tokenSource: TokenSource; fetchFn?: FetchFn },
179
+ ): Promise<{ name: string }> {
180
+ const data = (await callChatApi(
181
+ `${space}/messages`,
182
+ { method: "POST", body: { cardsV2: [{ cardId: "card", card }] } },
183
+ deps,
184
+ )) as { name: string };
185
+ return { name: data.name };
186
+ }
187
+
188
+ /** Edits an already-sent card message's `cardsV2` in place (PATCH, `updateMask=cardsV2`) — used to patch a tool-call status card from loading to its final outcome without sending a new message. */
189
+ export async function updateCard(
190
+ name: string,
191
+ card: ChatCard,
192
+ deps: { tokenSource: TokenSource; fetchFn?: FetchFn },
193
+ ): Promise<{ name: string }> {
194
+ const data = (await callChatApi(
195
+ `${name}?updateMask=cardsV2`,
196
+ { method: "PATCH", body: { cardsV2: [{ cardId: "card", card }] } },
197
+ deps,
198
+ )) as { name: string };
199
+ return { name: data.name };
200
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Splits a complete final answer into more than one Google Chat message
3
+ * only when it would exceed the API's own message-size limit — a purely
4
+ * technical safety net, not a readability choice (see
5
+ * `google-chat-streamer.ts`, the only caller). Google Chat rejects a
6
+ * message over 32,000 bytes ("the maximum message size... is 32,000
7
+ * bytes... your Chat app must send multiple messages instead" —
8
+ * https://developers.google.com/workspace/chat/create-messages). A local
9
+ * model's answer coming anywhere close to that is effectively never
10
+ * observed in practice, but the cut stays in place as a correctness net
11
+ * regardless. Caller: `google-chat-provider.ts`.
12
+ */
13
+
14
+ // Characters, not bytes — a wide margin under the real 32,000-byte limit.
15
+ // Multi-byte UTF-8 characters (accents, emoji) mean chars != bytes, but the
16
+ // margin is generous enough that only text many times longer than any real
17
+ // answer would ever need the exact byte count instead of this estimate.
18
+ export const MAX_CHAT_MESSAGE_CHARS = 30_000;
19
+
20
+ function lastWhitespaceIndex(s: string): number {
21
+ for (let i = s.length - 1; i >= 0; i--) {
22
+ if (/\s/.test(s[i] as string)) return i;
23
+ }
24
+ return -1;
25
+ }
26
+
27
+ /**
28
+ * Splits `text` at the last whitespace at-or-before `MAX_CHAT_MESSAGE_CHARS`,
29
+ * repeated as needed — never splits a single word, even if that means one
30
+ * piece exceeds the cap (a single unbroken token longer than the cap is
31
+ * left intact, the one documented case where the cap can be exceeded).
32
+ * Returns `[]` for empty/whitespace-only input.
33
+ */
34
+ export function splitForSendLimit(text: string): string[] {
35
+ const messages: string[] = [];
36
+ let remaining = text.trim();
37
+
38
+ while (remaining.length > MAX_CHAT_MESSAGE_CHARS) {
39
+ const window = remaining.slice(0, MAX_CHAT_MESSAGE_CHARS);
40
+ const cutAt = lastWhitespaceIndex(window);
41
+ if (cutAt <= 0) break; // no safe cut point without splitting a word
42
+ const piece = remaining.slice(0, cutAt).trim();
43
+ if (piece.length === 0) break;
44
+ messages.push(piece);
45
+ remaining = remaining.slice(cutAt).trim();
46
+ }
47
+
48
+ if (remaining.length > 0) messages.push(remaining);
49
+ return messages;
50
+ }
@@ -0,0 +1,591 @@
1
+ /**
2
+ * The registered Chat app's `Provider` — replaces the retired
3
+ * impersonation-based channel (`google-chat-client.ts`/`google-chat-events.ts`/
4
+ * `google-chat-streamer.ts`/`google-chat-message-buffer.ts`, all deleted)
5
+ * wholesale, not as an adapter over it. Built directly against the
6
+ * chat-app model: `chat.bot`-scoped service-account auth
7
+ * (`google-chat-app-client.ts`), a single Pub/Sub subscription for the
8
+ * whole app (no more per-space Workspace Events subscriptions — a real
9
+ * simplification the old impersonation path needed and this one doesn't),
10
+ * and a real per-app bot identity.
11
+ *
12
+ * Session key is `space:sender` — same as the retired channel's own key
13
+ * (see `deriveSessionKey` for why a thread-keyed variant, tried initially,
14
+ * broke conversation continuity in DMs).
15
+ *
16
+ * The `[Da: X]` sender marker no longer needs a People-API round trip: a
17
+ * registered app's `MESSAGE` event already carries `sender.displayName`
18
+ * directly (confirmed live against a real captured event earlier this
19
+ * session) — `resolveSenderName`/`getUser`/the `resolved-name.md` wiki
20
+ * cache are not used by this provider at all.
21
+ */
22
+ import {
23
+ createTokenSource,
24
+ sendMessage,
25
+ sendCard,
26
+ updateCard,
27
+ getOrCreateDmSpace,
28
+ type ServiceAccountCredentials,
29
+ type TokenSource,
30
+ type ChatCard,
31
+ } from "./google-chat-app-client.ts";
32
+ import { openSubscription, type PubSubSubscription, type StreamMessage } from "./google-chat-pubsub-stream.ts";
33
+ import { splitForSendLimit } from "./google-chat-message-buffer.ts";
34
+ import {
35
+ detectPendingConfirmation,
36
+ PENDING_CONFIRMATION_NOTE,
37
+ NO_REPLY,
38
+ type PendingConfirmation,
39
+ type ToolOutcome,
40
+ type Provider,
41
+ type HandleTurn,
42
+ type TurnSink,
43
+ } from "@mercury-fw/channel-types";
44
+
45
+ /**
46
+ * Builds the confirmation card sent when a step stages an irreversible
47
+ * command (see `pending-confirmation.ts`). The button's parameters carry
48
+ * the token, so a click routes straight into the same `tryConfirm` path
49
+ * a bare token typed on the terminal uses (see `onCardClick`'s `confirm`
50
+ * case, below) — the user never has to see or type the token themselves.
51
+ */
52
+ function buildConfirmCard(pending: PendingConfirmation): ChatCard {
53
+ return {
54
+ header: { title: "Conferma richiesta" },
55
+ sections: [
56
+ {
57
+ widgets: [
58
+ { textParagraph: { text: `\`${pending.summary}\`` } },
59
+ {
60
+ buttonList: {
61
+ buttons: [
62
+ {
63
+ text: "Conferma",
64
+ onClick: { action: { function: "confirm", parameters: [{ key: "token", value: pending.token }] } },
65
+ },
66
+ ],
67
+ },
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ };
73
+ }
74
+
75
+ const TOOL_STATUS_LINES: Record<"loading" | ToolOutcome, string> = {
76
+ loading: "In corso…",
77
+ success: "Fatto.",
78
+ failed: "Non riuscito.",
79
+ pending: "In attesa di conferma.",
80
+ };
81
+
82
+ /**
83
+ * A tool-call/reasoning status card: `label` is a section `header`
84
+ * (Google Chat's native accordion title, always visible regardless of
85
+ * collapse state), `detail`/status are the two widgets folded behind it —
86
+ * `uncollapsibleWidgetsCount: 0` means both start collapsed. Sent once as
87
+ * "loading" (`createSink`'s `onToolStart`/first `onReasoningChunk`), then
88
+ * the very same message is patched in place to its final status
89
+ * (`onToolFinish`/`onReasoningEnd`) via `updateCardFn` — never a second
90
+ * message.
91
+ *
92
+ * The status is appended to the title too, not just left inside the
93
+ * collapsed body: a title stuck reading "Sto leggendo dati con jira…"
94
+ * (present progressive) forever, even once the card's own body says
95
+ * "Fatto.", looked like the card never updated at all — confirmed live
96
+ * (the body did patch, only the title didn't change).
97
+ *
98
+ * Native `collapsible` state is client-side only and resets to collapsed
99
+ * on every PATCH — confirmed live. Harmless for a card patched exactly
100
+ * twice (send, then one final patch): reasoning cards used to be patched
101
+ * every ~1s while streaming and forcibly kept open (`alwaysExpanded`) to
102
+ * work around it, which made the reset bug moot but leaked the
103
+ * in-progress reasoning text into a card the user might reopen mid-stream.
104
+ * Simpler fix, not just a workaround: `createSink`'s reasoning handling no
105
+ * longer patches this card at all while loading (see `onReasoningChunk`) —
106
+ * detail is only ever revealed in the single final patch, at which point
107
+ * nothing patches the message again, so the native toggle can never be
108
+ * reset out from under the user again either.
109
+ */
110
+ function buildToolCallCard(label: string, detail: string, status: "loading" | ToolOutcome): ChatCard {
111
+ return {
112
+ sections: [
113
+ {
114
+ header: status === "loading" ? label : `${label} ${TOOL_STATUS_LINES[status]}`,
115
+ collapsible: true,
116
+ uncollapsibleWidgetsCount: 0,
117
+ widgets: [{ textParagraph: { text: detail } }, { textParagraph: { text: TOOL_STATUS_LINES[status] } }],
118
+ },
119
+ ],
120
+ };
121
+ }
122
+
123
+ const REASONING_TAIL_CHARS = 4000;
124
+
125
+ /**
126
+ * Sent immediately when a turn starts, before the model has produced
127
+ * anything at all — covers the real gap (confirmed live, ~10s) between a
128
+ * message arriving and the first visible activity: Ollama's own
129
+ * prompt-prefill/model-load time, which happens before even the first
130
+ * reasoning token, so nothing else would appear on screen until then.
131
+ * Ollama's `/api/generate`/`/api/chat` streaming endpoints don't expose
132
+ * any intermediate loading/prefill event to distinguish finer-grained
133
+ * states here (checked against the API docs) — `load_duration`/
134
+ * `prompt_eval_duration` only appear in the final, non-streamed response.
135
+ * Header is the generic "Stato" (not a restatement of the body) so this
136
+ * has somewhere to grow if a genuinely different state ever becomes
137
+ * observable, rather than needing a redesign.
138
+ *
139
+ * Not expandable (no `collapsible`) — there's nothing to fold away. A
140
+ * section with zero widgets renders as a near-empty sliver in Google Chat
141
+ * (confirmed live — a thin grey line, no visible text at all), so this
142
+ * always carries at least one `textParagraph`, same as every other card
143
+ * in this file. If the turn's first reasoning burst arrives,
144
+ * `onReasoningChunk` patches this exact card into the "Sto pensando…"
145
+ * card in place, rather than sending a second message; otherwise it's
146
+ * simply left as the last status before the turn's real answer.
147
+ */
148
+ function buildReceivingCard(): ChatCard {
149
+ return { sections: [{ header: "Stato", widgets: [{ textParagraph: { text: "Messaggio in ricezione…" } }] }] };
150
+ }
151
+
152
+ /**
153
+ * Bounds a live-growing reasoning buffer to its most recent
154
+ * `max` characters, prefixed with "…" when truncated — deliberately the
155
+ * opposite direction of `tool-start-hook.ts`'s `truncate()` (which keeps
156
+ * the head, right for a short command): a live stream should show its
157
+ * most recent content as it grows, not freeze on its first N characters.
158
+ */
159
+ function tailTruncate(text: string, max: number): string {
160
+ return text.length <= max ? text : `…${text.slice(text.length - max)}`;
161
+ }
162
+
163
+ /**
164
+ * Composite session key: space + sender. Deliberately does NOT include
165
+ * `thread` — confirmed live (two consecutive messages in the same DM
166
+ * conversation produced two different `thread.name` values) that Google
167
+ * Chat assigns a fresh thread to every top-level message in a DM (the DM
168
+ * UI has no way to reply in-thread at all), so a thread-keyed session was
169
+ * a brand new, empty history on every single message. Matches the retired
170
+ * impersonation-based channel's own key exactly. Revisit only once Mercury
171
+ * actually participates in threaded group spaces with a demonstrated need
172
+ * for parallel per-thread conversations — not speculatively.
173
+ */
174
+ export function deriveSessionKey(space: string, sender: string): string {
175
+ return `${space}:${sender}`;
176
+ }
177
+
178
+ type RawChatSender = { name?: string; displayName?: string; email?: string; type?: string };
179
+ type RawChatEvent = {
180
+ type?: string;
181
+ message?: {
182
+ name?: string;
183
+ text?: string;
184
+ space?: { name?: string; type?: string };
185
+ sender?: RawChatSender;
186
+ };
187
+ action?: { actionMethodName?: string; parameters?: Array<{ key?: string; value?: string }> };
188
+ space?: { name?: string };
189
+ user?: RawChatSender;
190
+ };
191
+
192
+ export type ParsedMessageEvent = {
193
+ kind: "message";
194
+ text: string;
195
+ messageName: string;
196
+ space: string;
197
+ sender: string;
198
+ senderDisplayName: string | undefined;
199
+ /**
200
+ * True only when `space.type` is exactly `"DM"` (the Chat API's shape
201
+ * for a private 1:1 space) — anything else, including a missing field,
202
+ * stays `false` on purpose: a DM is unambiguously addressed to Mercury,
203
+ * everything else keeps the cautious multi-user default. Confirmed
204
+ * live against a real DM event this session.
205
+ */
206
+ isDirectMessage: boolean;
207
+ };
208
+
209
+ export type ParsedCardClickEvent = {
210
+ kind: "card-click";
211
+ space: string;
212
+ sender: string;
213
+ parameters: Record<string, string>;
214
+ };
215
+
216
+ /** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
217
+ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null {
218
+ const event = raw as RawChatEvent;
219
+ if (event?.type === "MESSAGE") {
220
+ const m = event.message;
221
+ if (!m || typeof m.name !== "string" || typeof m.text !== "string") return null;
222
+ if (typeof m.space?.name !== "string") return null;
223
+ if (typeof m.sender?.name !== "string") return null;
224
+ return {
225
+ kind: "message",
226
+ text: m.text,
227
+ messageName: m.name,
228
+ space: m.space.name,
229
+ sender: m.sender.name,
230
+ senderDisplayName: m.sender.displayName,
231
+ isDirectMessage: m.space.type === "DM",
232
+ };
233
+ }
234
+ if (event?.type === "CARD_CLICKED") {
235
+ if (typeof event.space?.name !== "string") return null;
236
+ if (typeof event.user?.name !== "string") return null;
237
+ const parameters: Record<string, string> = {};
238
+ for (const p of event.action?.parameters ?? []) {
239
+ if (typeof p.key === "string" && typeof p.value === "string") {
240
+ parameters[p.key] = p.value;
241
+ }
242
+ }
243
+ return { kind: "card-click", space: event.space.name, sender: event.user.name, parameters };
244
+ }
245
+ return null;
246
+ }
247
+
248
+ export type CardClickHandler = (params: Record<string, string>, space: string, sender: string) => Promise<void>;
249
+
250
+ export type GoogleChatProviderDeps = {
251
+ credentials: ServiceAccountCredentials;
252
+ subscription: string;
253
+ /**
254
+ * Resolves a confirmation token against the core's shared store, injected by
255
+ * the channel loader (`ChannelRuntimeContext.confirm`). Returns the reply to
256
+ * send, or `null` when the input wasn't token-shaped. This is the whole of
257
+ * the channel's contact with confirmation — the store, vault and note-writer
258
+ * stay in the core.
259
+ */
260
+ confirm: (token: string, sessionKey: string, userId: string) => Promise<string | null>;
261
+ /**
262
+ * Handles a `CARD_CLICKED` event's action parameters. Defaults to resolving
263
+ * the confirm button's token through `deps.confirm` (the same path a bare
264
+ * token typed on the terminal uses) — override only to handle a different
265
+ * card's click shape (e.g. a future `notify-user` disambiguation token), not
266
+ * to change how confirmation itself resolves.
267
+ */
268
+ onCardClick?: CardClickHandler;
269
+ /** Test seams — default to the real client functions bound with a token source built from `credentials`. */
270
+ tokenSourceFn?: (creds: ServiceAccountCredentials) => TokenSource;
271
+ sendMessageFn?: typeof sendMessage;
272
+ sendCardFn?: typeof sendCard;
273
+ updateCardFn?: typeof updateCard;
274
+ getOrCreateDmSpaceFn?: typeof getOrCreateDmSpace;
275
+ /** Test seam — defaults to `openSubscription` (real StreamingPull); tests inject a fake `PubSubSubscription`. */
276
+ subscriptionFn?: (creds: ServiceAccountCredentials, subscription: string) => PubSubSubscription;
277
+ log?: (msg: string) => void;
278
+ };
279
+
280
+ export type GoogleChatProvider = Provider & {
281
+ stop(): Promise<void>;
282
+ };
283
+
284
+ /** Builds the registered Google Chat app's `Provider`. */
285
+ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleChatProvider {
286
+ const log = deps.log ?? ((msg: string) => console.error(msg));
287
+ const tokenSource = (deps.tokenSourceFn ?? createTokenSource)(deps.credentials);
288
+ const sendMessageFn = deps.sendMessageFn ?? sendMessage;
289
+ const sendCardFn = deps.sendCardFn ?? sendCard;
290
+ const updateCardFn = deps.updateCardFn ?? updateCard;
291
+ const getOrCreateDmSpaceFn = deps.getOrCreateDmSpaceFn ?? getOrCreateDmSpace;
292
+ const subscriptionFn = deps.subscriptionFn ?? openSubscription;
293
+ const clientDeps = { tokenSource };
294
+ const sentMessageNames = new Set<string>();
295
+ // Per-session serialization: pollOnce's setInterval fires on a fixed
296
+ // clock regardless of whether the previous tick's turn finished, so two
297
+ // overlapping ticks could otherwise both call handleTurn for the SAME
298
+ // session at once — both reading/writing the same SessionHistory
299
+ // concurrently. Scoped by sessionKey only (not global), so a slow turn
300
+ // for one user/space never blocks a different one's.
301
+ const busySessions = new Set<string>();
302
+ const queuedEvents = new Map<string, ParsedMessageEvent[]>();
303
+
304
+ /**
305
+ * Default `onCardClick`: the confirm button's token routes through the exact
306
+ * same `deps.confirm` a bare token typed on the terminal uses — one execution
307
+ * path, one set of valid/expired/wrong-session-token behaviors, regardless of
308
+ * how the token got here.
309
+ */
310
+ const onCardClick: CardClickHandler =
311
+ deps.onCardClick ??
312
+ (async (params, space, sender) => {
313
+ const token = params.token;
314
+ if (!token) {
315
+ log(`[chat] card click with no token parameter`);
316
+ return;
317
+ }
318
+ const reply = await deps.confirm(token, deriveSessionKey(space, sender), sender);
319
+ if (reply !== null) {
320
+ log(`[chat:${space}] [out] ${reply}`);
321
+ const sent = await sendMessageFn(space, reply, clientDeps);
322
+ sentMessageNames.add(sent.name);
323
+ }
324
+ });
325
+ let activeSubscription: PubSubSubscription | undefined;
326
+
327
+ /** Per-turn output sink — same responsibilities as the retired `ChatStreamer`, rebuilt against the new client. */
328
+ function createSink(space: string): TurnSink {
329
+ let chain: Promise<void> = Promise.resolve();
330
+
331
+ function enqueue(fn: () => Promise<void>): void {
332
+ chain = chain.then(async () => {
333
+ try {
334
+ await fn();
335
+ } catch (err) {
336
+ log(`[chat:${space}] send failed: ${String(err)}`);
337
+ }
338
+ });
339
+ }
340
+ function sendPlain(text: string): void {
341
+ enqueue(async () => {
342
+ log(`[chat:${space}] [out] ${text}`);
343
+ const sent = await sendMessageFn(space, text, clientDeps);
344
+ sentMessageNames.add(sent.name);
345
+ });
346
+ }
347
+
348
+ // Keyed by toolCallId (or, for a capture-ping, the id its own caller
349
+ // generated — see index.ts's captureIncrement/processToolCorrections):
350
+ // lets onToolFinish patch the exact message onToolStart created for
351
+ // that same call, rather than guessing the most recent one.
352
+ const toolCards = new Map<string, { name: string; label: string; detail: string }>();
353
+
354
+ // Keyed by the SDK's own reasoning-block id, exactly like toolCards —
355
+ // a tool-calling turn can reason more than once (before a tool call,
356
+ // again after seeing its result), each burst a fully independent card,
357
+ // never a continuation of an earlier one.
358
+ const reasoningCards = new Map<string, { name: string | undefined; buffer: string }>();
359
+
360
+ // Claimed (patched into the turn's first reasoning card) by
361
+ // onReasoningChunk below, then cleared — a second reasoning burst in
362
+ // the same turn (e.g. after a tool call) always gets its own new card,
363
+ // never reuses this one.
364
+ let receivingCardName: string | undefined;
365
+ enqueue(async () => {
366
+ log(`[chat:${space}] [out] receiving card`);
367
+ const sent = await sendCardFn(space, buildReceivingCard(), clientDeps);
368
+ sentMessageNames.add(sent.name);
369
+ receivingCardName = sent.name;
370
+ });
371
+
372
+ return {
373
+ onToolStart: (label: string, detail?: string, toolCallId?: string) => {
374
+ if (toolCallId === undefined) {
375
+ sendPlain(`_${label}_`);
376
+ return;
377
+ }
378
+ enqueue(async () => {
379
+ const card = buildToolCallCard(label, detail ?? "", "loading");
380
+ log(`[chat:${space}] [out] tool card: ${label}`);
381
+ const sent = await sendCardFn(space, card, clientDeps);
382
+ sentMessageNames.add(sent.name);
383
+ toolCards.set(toolCallId, { name: sent.name, label, detail: detail ?? "" });
384
+ });
385
+ },
386
+ onToolFinish: (toolCallId: string, outcome: ToolOutcome) => {
387
+ enqueue(async () => {
388
+ const entry = toolCards.get(toolCallId);
389
+ if (!entry) {
390
+ log(`[chat:${space}] onToolFinish for unknown toolCallId ${toolCallId}`);
391
+ return;
392
+ }
393
+ const card = buildToolCallCard(entry.label, entry.detail, outcome);
394
+ log(`[chat:${space}] [out] patching ${entry.name} to "${outcome}"`);
395
+ const patched = await updateCardFn(entry.name, card, clientDeps);
396
+ log(`[chat:${space}] [out] patched ${patched.name}`);
397
+ toolCards.delete(toolCallId);
398
+ });
399
+ },
400
+ // Only ever fires for a model that actually supports Ollama's
401
+ // extended thinking (see src/index.ts's OLLAMA_THINK) — a
402
+ // non-reasoning model means this is simply never called, so no card
403
+ // is ever created for that turn.
404
+ onReasoningChunk: (chunk: string, id: string) => {
405
+ let entry = reasoningCards.get(id);
406
+ if (!entry) {
407
+ entry = { name: undefined, buffer: chunk };
408
+ reasoningCards.set(id, entry);
409
+ enqueue(async () => {
410
+ const card = buildToolCallCard("Sto pensando…", "", "loading");
411
+ if (receivingCardName !== undefined) {
412
+ // Replace the "Stato" placeholder in place instead of
413
+ // sending a second message.
414
+ log(`[chat:${space}] [out] reasoning card (${id}) — replacing receiving card`);
415
+ const patched = await updateCardFn(receivingCardName, card, clientDeps);
416
+ entry!.name = patched.name;
417
+ receivingCardName = undefined;
418
+ } else {
419
+ log(`[chat:${space}] [out] reasoning card (${id})`);
420
+ const sent = await sendCardFn(space, card, clientDeps);
421
+ sentMessageNames.add(sent.name);
422
+ entry!.name = sent.name;
423
+ }
424
+ });
425
+ return;
426
+ }
427
+ // Accumulated only, never patched here: no live peek at the
428
+ // reasoning text while it's still streaming — see buildToolCallCard's
429
+ // doc comment for why. Revealed once, in full, by onReasoningEnd.
430
+ entry.buffer += chunk;
431
+ },
432
+ onReasoningEnd: (id: string, failed: boolean) => {
433
+ log(`[chat:${space}] onReasoningEnd(${id}, failed=${failed})`);
434
+ const entry = reasoningCards.get(id);
435
+ if (!entry) return; // no reasoning happened for this id — no card to close
436
+ enqueue(async () => {
437
+ if (entry.name === undefined) return; // shouldn't happen given enqueue's own FIFO ordering, but stays defensive
438
+ const status = failed ? "failed" : "success";
439
+ const card = buildToolCallCard("Sto pensando…", tailTruncate(entry.buffer, REASONING_TAIL_CHARS), status);
440
+ log(`[chat:${space}] [out] reasoning patch (${id}): status=${status} bufferLen=${entry.buffer.length}`);
441
+ await updateCardFn(entry.name, card, clientDeps);
442
+ reasoningCards.delete(id);
443
+ });
444
+ },
445
+ // Deliberately absent: Google Chat only shows a message once fully
446
+ // sent, so incremental delivery never actually reaches a human
447
+ // faster — this MUST stay undefined, it's what keeps runTurn on its
448
+ // non-streaming path (see src/router/provider.ts's TurnSink).
449
+ onUsage: (inputTokens) => log(`[chat:${space}] [usage] inputTokens=${inputTokens ?? "?"}`),
450
+ onStep: (step) => {
451
+ const pending = detectPendingConfirmation(step);
452
+ if (pending) {
453
+ enqueue(async () => {
454
+ log(`[chat:${space}] [out] confirm card: ${pending.summary}`);
455
+ const sent = await sendCardFn(space, buildConfirmCard(pending), clientDeps);
456
+ sentMessageNames.add(sent.name);
457
+ });
458
+ }
459
+ },
460
+ finalize: async (finalText: string) => {
461
+ const trimmed = finalText.trim();
462
+ if (trimmed.length > 0 && trimmed !== NO_REPLY && trimmed !== PENDING_CONFIRMATION_NOTE) {
463
+ for (const message of splitForSendLimit(trimmed)) {
464
+ sendPlain(message);
465
+ }
466
+ }
467
+ await chain;
468
+ },
469
+ dispose: () => {},
470
+ };
471
+ }
472
+
473
+ async function processMessageEvent(event: ParsedMessageEvent, handleTurn: HandleTurn): Promise<void> {
474
+ if (sentMessageNames.has(event.messageName)) return;
475
+
476
+ log(`[chat:${event.space}:${event.sender}] [in] ${event.text}`);
477
+
478
+ const sessionKey = deriveSessionKey(event.space, event.sender);
479
+ const markedInput = event.senderDisplayName ? `[Da: ${event.senderDisplayName}]\n${event.text}` : event.text;
480
+
481
+ const confirmReply = await deps.confirm(event.text, sessionKey, event.sender);
482
+ if (confirmReply !== null) {
483
+ log(`[chat:${event.space}] [out] ${confirmReply}`);
484
+ const sent = await sendMessageFn(event.space, confirmReply, clientDeps);
485
+ sentMessageNames.add(sent.name);
486
+ return;
487
+ }
488
+
489
+ // Only the actual model turn needs serializing — tryConfirm above
490
+ // never touches SessionHistory, so it's always safe to run right
491
+ // away regardless of whether this session is mid-turn.
492
+ if (busySessions.has(sessionKey)) {
493
+ const queue = queuedEvents.get(sessionKey) ?? [];
494
+ queue.push(event);
495
+ queuedEvents.set(sessionKey, queue);
496
+ return;
497
+ }
498
+
499
+ busySessions.add(sessionKey);
500
+ try {
501
+ const sink = createSink(event.space);
502
+ await handleTurn(
503
+ {
504
+ channel: "google-chat",
505
+ multiUser: !event.isDirectMessage,
506
+ text: markedInput,
507
+ sessionKey,
508
+ userId: event.sender,
509
+ wikiUserId: encodeURIComponent(event.sender),
510
+ logPrefix: `[chat:${event.space}:${event.sender}] `,
511
+ },
512
+ sink,
513
+ );
514
+ } finally {
515
+ busySessions.delete(sessionKey);
516
+ const queue = queuedEvents.get(sessionKey);
517
+ const next = queue?.shift();
518
+ if (queue && queue.length === 0) queuedEvents.delete(sessionKey);
519
+ if (next) {
520
+ processMessageEvent(next, handleTurn).catch((err) => log(`[chat] queued event handling failed: ${String(err)}`));
521
+ }
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Acks each message immediately on arrival — before `handleTurn` (or
527
+ * `onCardClick`) ever runs, not after it resolves. Same pattern the
528
+ * open-source Hermes agent's Google Chat adapter uses (ack in the Pub/Sub
529
+ * callback, agent processing dispatched separately): a real multi-step
530
+ * turn routinely takes longer than the subscription's ack deadline
531
+ * (Google's default is 10s), so acking only once processing completes
532
+ * would leave a message open to being redelivered — and reprocessed a
533
+ * second time, concurrently — while still being worked on. Acking first
534
+ * decouples "message confirmed" from "turn finished" entirely, so no
535
+ * turn duration can trigger a redelivery. Accepted tradeoff, same one
536
+ * Hermes makes: a hard crash mid-turn loses that one message rather than
537
+ * risking an endless redelivery loop.
538
+ */
539
+ async function handleMessage(message: StreamMessage, handleTurn: HandleTurn): Promise<void> {
540
+ message.ack();
541
+ let parsed: ParsedMessageEvent | ParsedCardClickEvent | null;
542
+ try {
543
+ parsed = parseChatEvent(JSON.parse(message.data.toString("utf-8")));
544
+ } catch (err) {
545
+ log(`[chat] failed to parse Pub/Sub message: ${String(err)}`);
546
+ return;
547
+ }
548
+ if (!parsed) return;
549
+ try {
550
+ if (parsed.kind === "message") {
551
+ await processMessageEvent(parsed, handleTurn);
552
+ } else {
553
+ await onCardClick(parsed.parameters, parsed.space, parsed.sender);
554
+ }
555
+ } catch (err) {
556
+ log(`[chat] event handling failed: ${String(err)}`);
557
+ }
558
+ }
559
+
560
+ return {
561
+ async start(handleTurn: HandleTurn): Promise<void> {
562
+ const sub = subscriptionFn(deps.credentials, deps.subscription);
563
+ activeSubscription = sub;
564
+ sub.on("message", (message) => {
565
+ handleMessage(message, handleTurn).catch((err) => log(`[chat] event handling failed: ${String(err)}`));
566
+ });
567
+ // The SDK retries transient stream errors internally — this only
568
+ // fires for something it gave up on. Logged, not thrown: one bad
569
+ // stream event must never take down the rest of Mercury (same
570
+ // convention every other channel/poller loop in this project
571
+ // follows).
572
+ sub.on("error", (err) => log(`[chat] pubsub stream error: ${String(err)}`));
573
+ },
574
+
575
+ async stop(): Promise<void> {
576
+ await activeSubscription?.close();
577
+ },
578
+
579
+ async notify(userId: string, text: string): Promise<{ sessionKey: string }> {
580
+ // sessionKey is the DM space's name, not the sent message's — matches
581
+ // the retired channel's exact behavior (preserved, not fixed, per
582
+ // this plan's "known pre-existing inconsistency" note: a real
583
+ // conversation in that DM is keyed space:sender, not space.name
584
+ // alone; reconciling that is a separate decision).
585
+ const space = await getOrCreateDmSpaceFn(userId, clientDeps);
586
+ const sent = await sendMessageFn(space.name, text, clientDeps);
587
+ sentMessageNames.add(sent.name); // loop prevention applies here too — a proactive notification is still our own message if it comes back as an event
588
+ return { sessionKey: space.name };
589
+ },
590
+ };
591
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Wraps `@google-cloud/pubsub` for the one thing this app needs: a
3
+ * long-lived StreamingPull subscription that emits decoded events as they
4
+ * arrive, replacing the retired REST `pull`-in-a-`setInterval` loop
5
+ * (`pullEvents`/`acknowledge` in `google-chat-app-client.ts`) — synchronous
6
+ * Pull has no real long-polling (confirmed against Google's own docs: an
7
+ * empty response doesn't mean the server waited for a message), so a fixed
8
+ * poll interval was the only lever available with that API. StreamingPull
9
+ * is a bidirectional gRPC stream; hand-rolling that with `fetch` the way
10
+ * the REST pull/ack calls were isn't practical, hence the one real
11
+ * dependency this project takes on Google's own client library.
12
+ *
13
+ * Isolated in its own file so `google-chat-provider.ts` never imports the
14
+ * SDK directly — its own tests inject a fake via the `subscriptionFn` seam
15
+ * instead of touching real gRPC. Verified live (`docs/DECISIONS.md`) that
16
+ * this SDK's StreamingPull works cleanly under Bun in this project's real
17
+ * Docker image before this file was written.
18
+ */
19
+ import { PubSub } from "@google-cloud/pubsub";
20
+ import type { ServiceAccountCredentials } from "./google-chat-app-client.ts";
21
+
22
+ /** One incoming Pub/Sub message. `data` is already the raw message body — the SDK handles the wire-level base64 transport itself. */
23
+ export type StreamMessage = { data: Buffer; ack: () => void; nack: () => void };
24
+
25
+ /** The subset of `@google-cloud/pubsub`'s `Subscription` this app actually uses — narrowed so a fake can satisfy it in tests without depending on the real SDK's types. */
26
+ export type PubSubSubscription = {
27
+ on(event: "message", listener: (message: StreamMessage) => void): void;
28
+ on(event: "error", listener: (err: Error) => void): void;
29
+ close(): Promise<void>;
30
+ };
31
+
32
+ /** Splits `"projects/<id>/subscriptions/<name>"` into its two parts — thrown separately so a malformed env var fails fast and clearly, not deep inside the SDK's own error handling. */
33
+ export function parseSubscriptionName(resourceName: string): { projectId: string; subscriptionId: string } {
34
+ const match = resourceName.match(/^projects\/([^/]+)\/subscriptions\/([^/]+)$/);
35
+ if (!match) throw new Error(`unexpected Pub/Sub subscription resource name: ${resourceName}`);
36
+ return { projectId: match[1]!, subscriptionId: match[2]! };
37
+ }
38
+
39
+ /**
40
+ * Opens a StreamingPull subscription. Real gRPC — exercised by the Phase 0
41
+ * spike and live verification, not by unit tests (`google-chat-provider.ts`
42
+ * injects a fake `PubSubSubscription` via its own `subscriptionFn` seam for
43
+ * those). Credentials are passed directly (`client_email`/`private_key`),
44
+ * same service-account values already used for the Chat API's own
45
+ * JWT-bearer flow — no key file on disk, no second credential to manage.
46
+ */
47
+ export function openSubscription(credentials: ServiceAccountCredentials, resourceName: string): PubSubSubscription {
48
+ const { projectId, subscriptionId } = parseSubscriptionName(resourceName);
49
+ const pubsub = new PubSub({
50
+ projectId,
51
+ credentials: { client_email: credentials.clientEmail, private_key: credentials.privateKey },
52
+ });
53
+ return pubsub.subscription(subscriptionId) as unknown as PubSubSubscription;
54
+ }
package/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The Google Chat channel plugin: the `ChannelPlugin` the core's channel loader
3
+ * consumes. `build()` reads this channel's own config from `ctx.env` and
4
+ * constructs the provider; it returns `undefined` when no
5
+ * `GOOGLE_CHAT_PUBSUB_SUBSCRIPTION` is set (the instance isn't running Google
6
+ * Chat — present but inert). A subscription set with the app credentials
7
+ * missing is a misconfiguration: `build()` throws and the loader skips this
8
+ * channel fail-soft, leaving the rest of Mercury up.
9
+ */
10
+ import { CHANNEL_API_VERSION, type ChannelPlugin, type ChannelRuntimeContext } from "@mercury-fw/channel-types";
11
+ import { createGoogleChatProvider } from "./google-chat-provider.ts";
12
+
13
+ /** Reads a required env var, failing loudly (caught by the channel loader) instead of silently degrading. */
14
+ function require(env: ChannelRuntimeContext["env"], name: string): string {
15
+ const value = env[name];
16
+ if (!value) {
17
+ throw new Error(`${name} is not set`);
18
+ }
19
+ return value;
20
+ }
21
+
22
+ export const googleChatChannel: ChannelPlugin = {
23
+ apiVersion: CHANNEL_API_VERSION,
24
+ name: "google-chat",
25
+ build: (ctx) => {
26
+ // No subscription configured ⇒ this instance simply doesn't run Google Chat.
27
+ const subscription = ctx.env.GOOGLE_CHAT_PUBSUB_SUBSCRIPTION;
28
+ if (!subscription) {
29
+ return undefined;
30
+ }
31
+ return createGoogleChatProvider({
32
+ credentials: {
33
+ clientEmail: require(ctx.env, "GOOGLE_CHAT_APP_CLIENT_EMAIL"),
34
+ // A PEM key is multi-line; stored in a single-line env var with literal
35
+ // "\n" escapes — unescape before Node's crypto, which needs real newlines.
36
+ privateKey: require(ctx.env, "GOOGLE_CHAT_APP_PRIVATE_KEY").replace(/\\n/g, "\n"),
37
+ },
38
+ subscription,
39
+ confirm: ctx.confirm,
40
+ log: ctx.log,
41
+ });
42
+ },
43
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@mercury-fw/channel-google-chat",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/lucabro81/mercury-fw.git",
8
+ "directory": "packages/channels/channel-google-chat"
9
+ },
10
+ "files": [
11
+ "*.ts",
12
+ "dist",
13
+ "CHANGELOG.md",
14
+ "!**/*.test.ts"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "exports": {
20
+ ".": {
21
+ "mercury-fw-source": "./index.ts",
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./index.ts"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "test": "bun test",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "dependencies": {
31
+ "@google-cloud/pubsub": "^5.3.1"
32
+ },
33
+ "peerDependencies": {
34
+ "@mercury-fw/channel-types": ">=0.24.0 <1.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@mercury-fw/channel-types": "0.25.0",
38
+ "@mercury-fw/typescript-config": "*",
39
+ "@types/bun": "^1.4.0",
40
+ "typescript": "^6.0.3"
41
+ }
42
+ }