@mercury-fw/channel-google-chat 0.6.0 → 0.8.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,27 @@
1
1
  # @mercury-fw/channel-google-chat
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b2f8cfb: - A confirmation can offer several choices (for example "only this time" / "always for this host"): a plugin tool stages them with the new `stageChoices` of `SessionToolContext`, and gets one token per choice. The person takes one; the others die with it, and they all expire together.
8
+ - The paper-trail note of a group of choices is one, under its first token, and says which choice was taken.
9
+ - `detectPendingConfirmation` returns the `choices` a staging offers; a plain staging is one choice labelled `Conferma`.
10
+ - A confirmed action can hand its result back to the model (`resume` on a choice): instead of showing the person the raw result, the channel resumes the turn and the model answers on it, the result reaching it as the tool result of the call that staged it, never stored in the history.
11
+ - A resumed turn answers the person who confirmed even in a shared space, and the line standing for the confirmation isn't archived as something they said. A result nobody resumes within five minutes (the life of a confirmation token) is forgotten, and a restart loses it.
12
+ - `InboundTurn.resumeAfterConfirm` and `RESUME_AFTER_CONFIRM` (channel contract) carry the resumed turn; `ConfirmOutcome` has a `resume` status.
13
+ - Terminal: every choice is printed on its own line with its token; a confirmation that resumes runs the turn again.
14
+ - Google Chat: the confirmation card has one button per choice; a typed token or a clicked choice that resumes runs the turn again, in the session's queue. The channel now needs channel-types 0.51.0 or later.
15
+ - HTTP: the `pending` event carries `choices`; a token sent to `POST /turn` that resumes streams the model's answer on the same request; `POST /confirm` answers `resume: true`, and `POST /turn { resume: true, conversationId }` runs that turn. The channel now needs channel-types 0.51.0 or later.
16
+
17
+ ## 0.7.0
18
+
19
+ ### Minor Changes
20
+
21
+ - 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.
22
+ - 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.
23
+ - `@mercury-fw/channel-google-chat` needs `@mercury-fw/channel-types` 0.46.0 or later.
24
+
3
25
  ## 0.6.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -26,6 +26,8 @@ channels: [googleChatChannel],
26
26
 
27
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
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
+
29
31
  ## Table of contents
30
32
 
31
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
  }>;
@@ -58,10 +58,12 @@ export type ParsedCardClickEvent = {
58
58
  sender: string;
59
59
  senderEmail: string | undefined;
60
60
  parameters: Record<string, string>;
61
+ /** As on a message (see `ParsedMessageEvent.isDirectMessage`): for the turn a click resumes. */
62
+ isDirectMessage: boolean;
61
63
  };
62
64
  /** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
63
65
  export declare function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null;
64
- export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string) => Promise<void>;
66
+ export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string, isDirectMessage?: boolean) => Promise<void>;
65
67
  export type GoogleChatProviderDeps = {
66
68
  credentials: ServiceAccountCredentials;
67
69
  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,13 @@ 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,
39
+ RESUME_AFTER_CONFIRM,
37
40
  NO_REPLY,
41
+ type LoginRequest,
38
42
  type PendingConfirmation,
39
43
  type ToolOutcome,
40
44
  type Provider,
@@ -45,7 +49,8 @@ import {
45
49
 
46
50
  /**
47
51
  * Builds the confirmation card sent when a step stages an irreversible
48
- * command (see `pending-confirmation.ts`). The button's parameters carry
52
+ * command (see `pending-confirmation.ts`), one button per choice it offers
53
+ * (a single `Conferma` for a plain staging). Each button's parameters carry
49
54
  * the token, so a click routes straight into the same `tryConfirm` path
50
55
  * a bare token typed on the terminal uses (see `onCardClick`'s `confirm`
51
56
  * case, below) — the user never has to see or type the token themselves.
@@ -59,12 +64,10 @@ function buildConfirmCard(pending: PendingConfirmation): ChatCard {
59
64
  { textParagraph: { text: `\`${pending.summary}\`` } },
60
65
  {
61
66
  buttonList: {
62
- buttons: [
63
- {
64
- text: "Conferma",
65
- onClick: { action: { function: "confirm", parameters: [{ key: "token", value: pending.token }] } },
66
- },
67
- ],
67
+ buttons: pending.choices.map((choice) => ({
68
+ text: choice.label,
69
+ onClick: { action: { function: "confirm", parameters: [{ key: "token", value: choice.token }] } },
70
+ })),
68
71
  },
69
72
  },
70
73
  ],
@@ -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.",
@@ -186,7 +196,7 @@ type RawChatEvent = {
186
196
  sender?: RawChatSender;
187
197
  };
188
198
  action?: { actionMethodName?: string; parameters?: Array<{ key?: string; value?: string }> };
189
- space?: { name?: string };
199
+ space?: { name?: string; type?: string };
190
200
  user?: RawChatSender;
191
201
  };
192
202
 
@@ -215,6 +225,8 @@ export type ParsedCardClickEvent = {
215
225
  sender: string;
216
226
  senderEmail: string | undefined;
217
227
  parameters: Record<string, string>;
228
+ /** As on a message (see `ParsedMessageEvent.isDirectMessage`): for the turn a click resumes. */
229
+ isDirectMessage: boolean;
218
230
  };
219
231
 
220
232
  /** The email Google vouches for on `sender`: a person's only (`HUMAN`), never a bot's or an unknown kind's. */
@@ -250,12 +262,25 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
250
262
  parameters[p.key] = p.value;
251
263
  }
252
264
  }
253
- return { kind: "card-click", space: event.space.name, sender: event.user.name, senderEmail: emailOf(event.user), parameters };
265
+ return {
266
+ kind: "card-click",
267
+ space: event.space.name,
268
+ sender: event.user.name,
269
+ senderEmail: emailOf(event.user),
270
+ parameters,
271
+ isDirectMessage: event.space.type === "DM",
272
+ };
254
273
  }
255
274
  return null;
256
275
  }
257
276
 
258
- export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string) => Promise<void>;
277
+ export type CardClickHandler = (
278
+ params: Record<string, string>,
279
+ space: string,
280
+ sender: string,
281
+ senderEmail?: string,
282
+ isDirectMessage?: boolean,
283
+ ) => Promise<void>;
259
284
 
260
285
  export type GoogleChatProviderDeps = {
261
286
  credentials: ServiceAccountCredentials;
@@ -309,7 +334,50 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
309
334
  // concurrently. Scoped by sessionKey only (not global), so a slow turn
310
335
  // for one user/space never blocks a different one's.
311
336
  const busySessions = new Set<string>();
312
- const queuedEvents = new Map<string, ParsedMessageEvent[]>();
337
+ const queuedTurns = new Map<string, Array<() => Promise<void>>>();
338
+ // The turn handler `start` was given, for a turn a card click resumes.
339
+ let activeHandleTurn: HandleTurn | undefined;
340
+
341
+ /** Runs `turn` for `sessionKey` now, or after the turns already queued for it. */
342
+ async function runInSession(sessionKey: string, turn: () => Promise<void>): Promise<void> {
343
+ if (busySessions.has(sessionKey)) {
344
+ const queue = queuedTurns.get(sessionKey) ?? [];
345
+ queue.push(turn);
346
+ queuedTurns.set(sessionKey, queue);
347
+ return;
348
+ }
349
+ busySessions.add(sessionKey);
350
+ try {
351
+ await turn();
352
+ } finally {
353
+ busySessions.delete(sessionKey);
354
+ const queue = queuedTurns.get(sessionKey);
355
+ const next = queue?.shift();
356
+ if (queue && queue.length === 0) queuedTurns.delete(sessionKey);
357
+ if (next) {
358
+ runInSession(sessionKey, next).catch((err) => log(`[chat] queued turn failed: ${String(err)}`));
359
+ }
360
+ }
361
+ }
362
+
363
+ /** Resumes the session's turn after a confirmation whose result went back to the model (`RESUME_AFTER_CONFIRM`). */
364
+ function resumeTurn(handleTurn: HandleTurn, space: string, principal: Principal, isDirectMessage: boolean): Promise<void> {
365
+ const sessionKey = deriveSessionKey(space, principal.id);
366
+ return runInSession(sessionKey, () =>
367
+ handleTurn(
368
+ {
369
+ channel: "google-chat",
370
+ multiUser: !isDirectMessage,
371
+ text: "",
372
+ sessionKey,
373
+ principal,
374
+ logPrefix: `[chat:${space}:${principal.id}] `,
375
+ resumeAfterConfirm: true,
376
+ },
377
+ createSink(space, { user: principal.id, isDirectMessage }),
378
+ ),
379
+ );
380
+ }
313
381
 
314
382
  /**
315
383
  * Default `onCardClick`: the confirm button's token routes through the exact
@@ -319,17 +387,22 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
319
387
  */
320
388
  const onCardClick: CardClickHandler =
321
389
  deps.onCardClick ??
322
- (async (params, space, sender, senderEmail) => {
390
+ (async (params, space, sender, senderEmail, isDirectMessage = false) => {
323
391
  const token = params.token;
324
392
  if (!token) {
325
393
  log(`[chat] card click with no token parameter`);
326
394
  return;
327
395
  }
328
- const reply = await deps.confirm(token, deriveSessionKey(space, sender), {
396
+ const principal: Principal = {
329
397
  id: sender,
330
398
  provider: "google-chat",
331
399
  ...(senderEmail === undefined ? {} : { claims: { email: senderEmail } }),
332
- });
400
+ };
401
+ const reply = await deps.confirm(token, deriveSessionKey(space, sender), principal);
402
+ if (reply === RESUME_AFTER_CONFIRM && activeHandleTurn) {
403
+ await resumeTurn(activeHandleTurn, space, principal, isDirectMessage);
404
+ return;
405
+ }
333
406
  if (reply !== null) {
334
407
  log(`[chat:${space}] [out] ${reply}`);
335
408
  const sent = await sendMessageFn(space, reply, clientDeps);
@@ -338,8 +411,12 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
338
411
  });
339
412
  let activeSubscription: PubSubSubscription | undefined;
340
413
 
341
- /** Per-turn output sink — same responsibilities as the retired `ChatStreamer`, rebuilt against the new client. */
342
- function createSink(space: string): TurnSink {
414
+ /**
415
+ * Per-turn output sink — same responsibilities as the retired `ChatStreamer`,
416
+ * rebuilt against the new client. `viewer` is the person the turn is for;
417
+ * outside a direct message, what only they may see is sent to them alone.
418
+ */
419
+ function createSink(space: string, viewer: { user: string; isDirectMessage: boolean }): TurnSink {
343
420
  let chain: Promise<void> = Promise.resolve();
344
421
 
345
422
  function enqueue(fn: () => Promise<void>): void {
@@ -376,6 +453,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
376
453
  // the same turn (e.g. after a tool call) always gets its own new card,
377
454
  // never reuses this one.
378
455
  let receivingCardName: string | undefined;
456
+
457
+ // A tool asking again in the same turn hands back the same pending login.
458
+ const shownLogins = new Set<string>();
379
459
  enqueue(async () => {
380
460
  log(`[chat:${space}] [out] receiving card`);
381
461
  const sent = await sendCardFn(space, buildReceivingCard(), clientDeps);
@@ -470,6 +550,16 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
470
550
  sentMessageNames.add(sent.name);
471
551
  });
472
552
  }
553
+ const login = detectLoginRequired(step);
554
+ if (login && !shownLogins.has(login.url)) {
555
+ shownLogins.add(login.url);
556
+ enqueue(async () => {
557
+ log(`[chat:${space}] [out] login card: ${login.service}`);
558
+ const options = viewer.isDirectMessage ? undefined : { privateViewer: viewer.user };
559
+ const sent = await sendCardFn(space, buildLoginCard(login), clientDeps, options);
560
+ sentMessageNames.add(sent.name);
561
+ });
562
+ }
473
563
  },
474
564
  finalize: async (finalText: string) => {
475
565
  const trimmed = finalText.trim();
@@ -501,6 +591,10 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
501
591
  };
502
592
 
503
593
  const confirmReply = await deps.confirm(event.text, sessionKey, principal);
594
+ if (confirmReply === RESUME_AFTER_CONFIRM) {
595
+ await resumeTurn(handleTurn, event.space, principal, event.isDirectMessage);
596
+ return;
597
+ }
504
598
  if (confirmReply !== null) {
505
599
  log(`[chat:${event.space}] [out] ${confirmReply}`);
506
600
  const sent = await sendMessageFn(event.space, confirmReply, clientDeps);
@@ -511,17 +605,8 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
511
605
  // Only the actual model turn needs serializing — tryConfirm above
512
606
  // never touches SessionHistory, so it's always safe to run right
513
607
  // away regardless of whether this session is mid-turn.
514
- if (busySessions.has(sessionKey)) {
515
- const queue = queuedEvents.get(sessionKey) ?? [];
516
- queue.push(event);
517
- queuedEvents.set(sessionKey, queue);
518
- return;
519
- }
520
-
521
- busySessions.add(sessionKey);
522
- try {
523
- const sink = createSink(event.space);
524
- await handleTurn(
608
+ await runInSession(sessionKey, () =>
609
+ handleTurn(
525
610
  {
526
611
  channel: "google-chat",
527
612
  multiUser: !event.isDirectMessage,
@@ -530,17 +615,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
530
615
  principal,
531
616
  logPrefix: `[chat:${event.space}:${event.sender}] `,
532
617
  },
533
- sink,
534
- );
535
- } finally {
536
- busySessions.delete(sessionKey);
537
- const queue = queuedEvents.get(sessionKey);
538
- const next = queue?.shift();
539
- if (queue && queue.length === 0) queuedEvents.delete(sessionKey);
540
- if (next) {
541
- processMessageEvent(next, handleTurn).catch((err) => log(`[chat] queued event handling failed: ${String(err)}`));
542
- }
543
- }
618
+ createSink(event.space, { user: event.sender, isDirectMessage: event.isDirectMessage }),
619
+ ),
620
+ );
544
621
  }
545
622
 
546
623
  /**
@@ -571,7 +648,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
571
648
  if (parsed.kind === "message") {
572
649
  await processMessageEvent(parsed, handleTurn);
573
650
  } else {
574
- await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail);
651
+ await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail, parsed.isDirectMessage);
575
652
  }
576
653
  } catch (err) {
577
654
  log(`[chat] event handling failed: ${String(err)}`);
@@ -580,6 +657,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
580
657
 
581
658
  return {
582
659
  async start(handleTurn: HandleTurn): Promise<void> {
660
+ activeHandleTurn = handleTurn;
583
661
  const sub = subscriptionFn(deps.credentials, deps.subscription);
584
662
  activeSubscription = sub;
585
663
  sub.on("message", (message) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-google-chat",
3
- "version": "0.6.0",
3
+ "version": "0.8.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.51.0 <1.0.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@mercury-fw/channel-types": "0.44.1",
37
+ "@mercury-fw/channel-types": "0.51.0",
38
38
  "@mercury-fw/typescript-config": "*",
39
39
  "@types/bun": "^1.4.2",
40
40
  "typescript": "^6.0.3"