@mercury-fw/channel-google-chat 0.5.0 → 0.7.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/CHANGELOG.md +18 -0
- package/README.md +4 -0
- package/dist/google-chat-app-client.d.ts +7 -1
- package/dist/google-chat-provider.d.ts +4 -1
- package/google-chat-app-client.ts +11 -6
- package/google-chat-provider.ts +50 -8
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @mercury-fw/channel-google-chat
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4184ea6: - `loginLinkText` words a login link the same way on every channel, and the contract states that a login link is shown only to the person it's for.
|
|
8
|
+
- Google Chat shows the login to a service a plugin acts on as the person (Jira, Bitbucket) as a button that opens it, visible only to them: in a shared space it's a private message to whoever asked.
|
|
9
|
+
- `@mercury-fw/channel-google-chat` needs `@mercury-fw/channel-types` 0.46.0 or later.
|
|
10
|
+
|
|
11
|
+
## 0.6.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- c24de78: A Google Chat sender is recognised as their ZITADEL user.
|
|
16
|
+
|
|
17
|
+
- `channel-google-chat` passes the sender's email on as a claim (`claims.email`), on messages and on card clicks, when Google vouches for it: a person's (`HUMAN`) only.
|
|
18
|
+
- `zitadelDirectory` resolves a Google Chat sender to the one active ZITADEL user with that email, verified, whose links to the Google identity provider (`ZITADEL_GOOGLE_IDP_ID`, new and optional) include the sender's own Google account. Without that variable nobody is recognised from Chat. Anyone else on Chat stays unknown and can link their account with a code.
|
|
19
|
+
- `mfw create` lists `ZITADEL_GOOGLE_IDP_ID` in the env example of an app with the ZITADEL directory.
|
|
20
|
+
|
|
3
21
|
## 0.5.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -24,6 +24,10 @@ channels: [googleChatChannel],
|
|
|
24
24
|
| `GOOGLE_CHAT_APP_CLIENT_EMAIL` | The service account the app authenticates as. |
|
|
25
25
|
| `GOOGLE_CHAT_APP_PRIVATE_KEY` | That service account's private key, on one line with literal `\n` (step 5 writes it that way). |
|
|
26
26
|
|
|
27
|
+
Each message reaches the core as `google-chat:users/<id>`, the sender's Chat user, with their email when Google vouches for it (a person's, never a bot's), which a user directory can join on: the ZITADEL directory of `@mercury-fw/plugin-zitadel` does.
|
|
28
|
+
|
|
29
|
+
When a plugin that acts as the person (Jira, Bitbucket) needs them to log in, the channel sends a card with a button that opens the service's login, visible only to that person: in a direct message the space is already theirs, in a shared space the card is a private message to whoever asked, since anyone else opening it would log their own account in as them. The service sends the browser back to the HTTP channel's `/login/callback`, so the app needs [`@mercury-fw/channel-http`](https://www.npmjs.com/package/@mercury-fw/channel-http) with `HTTP_SURFACE_PUBLIC_URL` too; without it Mercury answers that nobody can log in on this instance.
|
|
30
|
+
|
|
27
31
|
## Table of contents
|
|
28
32
|
|
|
29
33
|
- [Setting up the Chat app](#setting-up-the-chat-app)
|
|
@@ -55,10 +55,16 @@ export type ChatCard = {
|
|
|
55
55
|
};
|
|
56
56
|
sections: ChatCardSection[];
|
|
57
57
|
};
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Posts `card` as a Cards v2 message to `space`. Returns the created message's
|
|
60
|
+
* `name`, same shape as `sendMessage`. With `privateViewer` (`users/<id>`) only
|
|
61
|
+
* that user and the app see it, in a space anyone else is in too.
|
|
62
|
+
*/
|
|
59
63
|
export declare function sendCard(space: string, card: ChatCard, deps: {
|
|
60
64
|
tokenSource: TokenSource;
|
|
61
65
|
fetchFn?: FetchFn;
|
|
66
|
+
}, options?: {
|
|
67
|
+
privateViewer?: string;
|
|
62
68
|
}): Promise<{
|
|
63
69
|
name: string;
|
|
64
70
|
}>;
|
|
@@ -41,6 +41,8 @@ export type ParsedMessageEvent = {
|
|
|
41
41
|
space: string;
|
|
42
42
|
sender: string;
|
|
43
43
|
senderDisplayName: string | undefined;
|
|
44
|
+
/** The sender's email, which Google vouches for: a person's only, never a bot's. */
|
|
45
|
+
senderEmail: string | undefined;
|
|
44
46
|
/**
|
|
45
47
|
* True only when `space.type` is exactly `"DM"` (the Chat API's shape
|
|
46
48
|
* for a private 1:1 space) — anything else, including a missing field,
|
|
@@ -54,11 +56,12 @@ export type ParsedCardClickEvent = {
|
|
|
54
56
|
kind: "card-click";
|
|
55
57
|
space: string;
|
|
56
58
|
sender: string;
|
|
59
|
+
senderEmail: string | undefined;
|
|
57
60
|
parameters: Record<string, string>;
|
|
58
61
|
};
|
|
59
62
|
/** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
|
|
60
63
|
export declare function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null;
|
|
61
|
-
export type CardClickHandler = (params: Record<string, string>, space: string, sender: string) => Promise<void>;
|
|
64
|
+
export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string) => Promise<void>;
|
|
62
65
|
export type GoogleChatProviderDeps = {
|
|
63
66
|
credentials: ServiceAccountCredentials;
|
|
64
67
|
subscription: string;
|
|
@@ -171,17 +171,22 @@ export type ChatCardSection = {
|
|
|
171
171
|
};
|
|
172
172
|
export type ChatCard = { header?: { title: string }; sections: ChatCardSection[] };
|
|
173
173
|
|
|
174
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* Posts `card` as a Cards v2 message to `space`. Returns the created message's
|
|
176
|
+
* `name`, same shape as `sendMessage`. With `privateViewer` (`users/<id>`) only
|
|
177
|
+
* that user and the app see it, in a space anyone else is in too.
|
|
178
|
+
*/
|
|
175
179
|
export async function sendCard(
|
|
176
180
|
space: string,
|
|
177
181
|
card: ChatCard,
|
|
178
182
|
deps: { tokenSource: TokenSource; fetchFn?: FetchFn },
|
|
183
|
+
options: { privateViewer?: string } = {},
|
|
179
184
|
): Promise<{ name: string }> {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
)) as { name: string };
|
|
185
|
+
const body = {
|
|
186
|
+
cardsV2: [{ cardId: "card", card }],
|
|
187
|
+
...(options.privateViewer === undefined ? {} : { privateMessageViewer: { name: options.privateViewer } }),
|
|
188
|
+
};
|
|
189
|
+
const data = (await callChatApi(`${space}/messages`, { method: "POST", body }, deps)) as { name: string };
|
|
185
190
|
return { name: data.name };
|
|
186
191
|
}
|
|
187
192
|
|
package/google-chat-provider.ts
CHANGED
|
@@ -32,9 +32,12 @@ import {
|
|
|
32
32
|
import { openSubscription, type PubSubSubscription, type StreamMessage } from "./google-chat-pubsub-stream.ts";
|
|
33
33
|
import { splitForSendLimit } from "./google-chat-message-buffer.ts";
|
|
34
34
|
import {
|
|
35
|
+
detectLoginRequired,
|
|
35
36
|
detectPendingConfirmation,
|
|
37
|
+
loginLinkText,
|
|
36
38
|
PENDING_CONFIRMATION_NOTE,
|
|
37
39
|
NO_REPLY,
|
|
40
|
+
type LoginRequest,
|
|
38
41
|
type PendingConfirmation,
|
|
39
42
|
type ToolOutcome,
|
|
40
43
|
type Provider,
|
|
@@ -73,6 +76,13 @@ function buildConfirmCard(pending: PendingConfirmation): ChatCard {
|
|
|
73
76
|
};
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/** The card that shows a person the link to log in to a service a plugin acts on as them: one button that opens it. */
|
|
80
|
+
function buildLoginCard(login: LoginRequest): ChatCard {
|
|
81
|
+
return {
|
|
82
|
+
sections: [{ widgets: [{ buttonList: { buttons: [{ text: loginLinkText(login), onClick: { openLink: { url: login.url } } }] } }] }],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
const TOOL_STATUS_LINES: Record<"loading" | ToolOutcome, string> = {
|
|
77
87
|
loading: "In corso…",
|
|
78
88
|
success: "Fatto.",
|
|
@@ -197,6 +207,8 @@ export type ParsedMessageEvent = {
|
|
|
197
207
|
space: string;
|
|
198
208
|
sender: string;
|
|
199
209
|
senderDisplayName: string | undefined;
|
|
210
|
+
/** The sender's email, which Google vouches for: a person's only, never a bot's. */
|
|
211
|
+
senderEmail: string | undefined;
|
|
200
212
|
/**
|
|
201
213
|
* True only when `space.type` is exactly `"DM"` (the Chat API's shape
|
|
202
214
|
* for a private 1:1 space) — anything else, including a missing field,
|
|
@@ -211,9 +223,15 @@ export type ParsedCardClickEvent = {
|
|
|
211
223
|
kind: "card-click";
|
|
212
224
|
space: string;
|
|
213
225
|
sender: string;
|
|
226
|
+
senderEmail: string | undefined;
|
|
214
227
|
parameters: Record<string, string>;
|
|
215
228
|
};
|
|
216
229
|
|
|
230
|
+
/** The email Google vouches for on `sender`: a person's only (`HUMAN`), never a bot's or an unknown kind's. */
|
|
231
|
+
function emailOf(sender: RawChatSender | undefined): string | undefined {
|
|
232
|
+
return typeof sender?.email === "string" && sender.email !== "" && sender.type === "HUMAN" ? sender.email : undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
217
235
|
/** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
|
|
218
236
|
export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null {
|
|
219
237
|
const event = raw as RawChatEvent;
|
|
@@ -229,6 +247,7 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
|
|
|
229
247
|
space: m.space.name,
|
|
230
248
|
sender: m.sender.name,
|
|
231
249
|
senderDisplayName: m.sender.displayName,
|
|
250
|
+
senderEmail: emailOf(m.sender),
|
|
232
251
|
isDirectMessage: m.space.type === "DM",
|
|
233
252
|
};
|
|
234
253
|
}
|
|
@@ -241,12 +260,12 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
|
|
|
241
260
|
parameters[p.key] = p.value;
|
|
242
261
|
}
|
|
243
262
|
}
|
|
244
|
-
return { kind: "card-click", space: event.space.name, sender: event.user.name, parameters };
|
|
263
|
+
return { kind: "card-click", space: event.space.name, sender: event.user.name, senderEmail: emailOf(event.user), parameters };
|
|
245
264
|
}
|
|
246
265
|
return null;
|
|
247
266
|
}
|
|
248
267
|
|
|
249
|
-
export type CardClickHandler = (params: Record<string, string>, space: string, sender: string) => Promise<void>;
|
|
268
|
+
export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string) => Promise<void>;
|
|
250
269
|
|
|
251
270
|
export type GoogleChatProviderDeps = {
|
|
252
271
|
credentials: ServiceAccountCredentials;
|
|
@@ -310,13 +329,17 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
310
329
|
*/
|
|
311
330
|
const onCardClick: CardClickHandler =
|
|
312
331
|
deps.onCardClick ??
|
|
313
|
-
(async (params, space, sender) => {
|
|
332
|
+
(async (params, space, sender, senderEmail) => {
|
|
314
333
|
const token = params.token;
|
|
315
334
|
if (!token) {
|
|
316
335
|
log(`[chat] card click with no token parameter`);
|
|
317
336
|
return;
|
|
318
337
|
}
|
|
319
|
-
const reply = await deps.confirm(token, deriveSessionKey(space, sender), {
|
|
338
|
+
const reply = await deps.confirm(token, deriveSessionKey(space, sender), {
|
|
339
|
+
id: sender,
|
|
340
|
+
provider: "google-chat",
|
|
341
|
+
...(senderEmail === undefined ? {} : { claims: { email: senderEmail } }),
|
|
342
|
+
});
|
|
320
343
|
if (reply !== null) {
|
|
321
344
|
log(`[chat:${space}] [out] ${reply}`);
|
|
322
345
|
const sent = await sendMessageFn(space, reply, clientDeps);
|
|
@@ -325,8 +348,12 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
325
348
|
});
|
|
326
349
|
let activeSubscription: PubSubSubscription | undefined;
|
|
327
350
|
|
|
328
|
-
/**
|
|
329
|
-
|
|
351
|
+
/**
|
|
352
|
+
* Per-turn output sink — same responsibilities as the retired `ChatStreamer`,
|
|
353
|
+
* rebuilt against the new client. `viewer` is the person the turn is for;
|
|
354
|
+
* outside a direct message, what only they may see is sent to them alone.
|
|
355
|
+
*/
|
|
356
|
+
function createSink(space: string, viewer: { user: string; isDirectMessage: boolean }): TurnSink {
|
|
330
357
|
let chain: Promise<void> = Promise.resolve();
|
|
331
358
|
|
|
332
359
|
function enqueue(fn: () => Promise<void>): void {
|
|
@@ -363,6 +390,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
363
390
|
// the same turn (e.g. after a tool call) always gets its own new card,
|
|
364
391
|
// never reuses this one.
|
|
365
392
|
let receivingCardName: string | undefined;
|
|
393
|
+
|
|
394
|
+
// A tool asking again in the same turn hands back the same pending login.
|
|
395
|
+
const shownLogins = new Set<string>();
|
|
366
396
|
enqueue(async () => {
|
|
367
397
|
log(`[chat:${space}] [out] receiving card`);
|
|
368
398
|
const sent = await sendCardFn(space, buildReceivingCard(), clientDeps);
|
|
@@ -457,6 +487,16 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
457
487
|
sentMessageNames.add(sent.name);
|
|
458
488
|
});
|
|
459
489
|
}
|
|
490
|
+
const login = detectLoginRequired(step);
|
|
491
|
+
if (login && !shownLogins.has(login.url)) {
|
|
492
|
+
shownLogins.add(login.url);
|
|
493
|
+
enqueue(async () => {
|
|
494
|
+
log(`[chat:${space}] [out] login card: ${login.service}`);
|
|
495
|
+
const options = viewer.isDirectMessage ? undefined : { privateViewer: viewer.user };
|
|
496
|
+
const sent = await sendCardFn(space, buildLoginCard(login), clientDeps, options);
|
|
497
|
+
sentMessageNames.add(sent.name);
|
|
498
|
+
});
|
|
499
|
+
}
|
|
460
500
|
},
|
|
461
501
|
finalize: async (finalText: string) => {
|
|
462
502
|
const trimmed = finalText.trim();
|
|
@@ -483,6 +523,8 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
483
523
|
id: event.sender,
|
|
484
524
|
provider: "google-chat",
|
|
485
525
|
...(event.senderDisplayName ? { displayName: event.senderDisplayName } : {}),
|
|
526
|
+
// A directory joins a Chat identity to a person on it (see the ZITADEL directory).
|
|
527
|
+
...(event.senderEmail === undefined ? {} : { claims: { email: event.senderEmail } }),
|
|
486
528
|
};
|
|
487
529
|
|
|
488
530
|
const confirmReply = await deps.confirm(event.text, sessionKey, principal);
|
|
@@ -505,7 +547,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
505
547
|
|
|
506
548
|
busySessions.add(sessionKey);
|
|
507
549
|
try {
|
|
508
|
-
const sink = createSink(event.space);
|
|
550
|
+
const sink = createSink(event.space, { user: event.sender, isDirectMessage: event.isDirectMessage });
|
|
509
551
|
await handleTurn(
|
|
510
552
|
{
|
|
511
553
|
channel: "google-chat",
|
|
@@ -556,7 +598,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
556
598
|
if (parsed.kind === "message") {
|
|
557
599
|
await processMessageEvent(parsed, handleTurn);
|
|
558
600
|
} else {
|
|
559
|
-
await onCardClick(parsed.parameters, parsed.space, parsed.sender);
|
|
601
|
+
await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail);
|
|
560
602
|
}
|
|
561
603
|
} catch (err) {
|
|
562
604
|
log(`[chat] event handling failed: ${String(err)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mercury-fw/channel-google-chat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
"@google-cloud/pubsub": "^5.3.1"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
|
-
"@mercury-fw/channel-types": ">=0.
|
|
34
|
+
"@mercury-fw/channel-types": ">=0.46.0 <1.0.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@mercury-fw/channel-types": "0.
|
|
37
|
+
"@mercury-fw/channel-types": "0.46.0",
|
|
38
38
|
"@mercury-fw/typescript-config": "*",
|
|
39
39
|
"@types/bun": "^1.4.2",
|
|
40
40
|
"typescript": "^6.0.3"
|