@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 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
- /** Posts `card` as a Cards v2 message to `space`. Returns the created message's `name`, same shape as `sendMessage`. */
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
- /** Posts `card` as a Cards v2 message to `space`. Returns the created message's `name`, same shape as `sendMessage`. */
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 data = (await callChatApi(
181
- `${space}/messages`,
182
- { method: "POST", body: { cardsV2: [{ cardId: "card", card }] } },
183
- deps,
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
 
@@ -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), { id: sender, provider: "google-chat" });
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
- /** Per-turn output sink — same responsibilities as the retired `ChatStreamer`, rebuilt against the new client. */
329
- function createSink(space: string): TurnSink {
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.5.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.36.0 <1.0.0"
34
+ "@mercury-fw/channel-types": ">=0.46.0 <1.0.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@mercury-fw/channel-types": "0.42.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"