@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 +23 -0
- package/dist/google-chat-app-client.d.ts +71 -0
- package/dist/google-chat-message-buffer.d.ts +21 -0
- package/dist/google-chat-provider.d.ts +95 -0
- package/dist/google-chat-pubsub-stream.d.ts +27 -0
- package/dist/index.d.ts +11 -0
- package/google-chat-app-client.ts +200 -0
- package/google-chat-message-buffer.ts +50 -0
- package/google-chat-provider.ts +591 -0
- package/google-chat-pubsub-stream.ts +54 -0
- package/index.ts +43 -0
- package/package.json +42 -0
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;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|