@mercury-fw/channel-google-chat 0.8.2 → 0.9.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,26 @@
1
1
  # @mercury-fw/channel-google-chat
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e00ff73: The channels' texts come out in the person's language too, and the language their client asks for counts. Channel contract version 6:
8
+
9
+ - The person's language is the one they saved, else the one their client asks for, else the app's default. The HTTP channel passes on the request's `Accept-Language` header (what browsers send), Google Chat the language of the person's Chat client when its event carries one (`InboundTurn.languageHint`). Someone the agent refuses gets it too.
10
+ - A channel declares its own texts in English in `messages`, under its name (`google-chat.`, `http.`), checked like a plugin's; it gets them through `ctx.i18n.forRequest(principal, hint)`.
11
+ - The replies to a confirmation are the core's, in the person's language: `ctx.confirm` returns `{ text, resume? }` and `ctx.resolveConfirmation` the outcome with its `text`. A confirmed action reads "Confirmed and done." instead of its raw result as JSON. `confirmReply`, `RESUME_AFTER_CONFIRM` and `PENDING_CONFIRMATION_NOTE` are gone from the contract, and `tryConfirm` from `@mercury-fw/confirm-engine` (`resolveConfirmation` reports the outcome; the core words it).
12
+ - The stand-in for an answer the model didn't write because the turn staged an action (`core.pending.note`, "Action pending, waiting for confirmation.") is no longer streamed as text: `finalize(text, { pendingNote })` says the text begins with it, and channels drop it through that, never by comparing text.
13
+ - A plain staging's single choice has an empty label: each channel words its own button ("Confirm" on Google Chat, in the person's language). `loginLinkText(login, t)` takes the person's texts.
14
+ - HTTP: `Accept-Language` on `/turn`, `/confirm` and `/login/callback`; `Content-Language` on the responses of `/turn` and `/confirm`; the `final` event carries `pendingNote`; the login page reads in the browser's language.
15
+ - Google Chat: the cards' titles, status lines and confirm button are `google-chat.*` texts; the client's `common.userLocale` is the hint when the event carries it.
16
+ - The terminal's replies to a confirmation read in the terminal person's language, like its other lines.
17
+
18
+ The texts that were in Italian (the cards, the replies to a confirmation, the pending note) are now English by default. An app that wants them in Italian adds their keys to `locales/it.json` (`core.pending.note`, `core.confirm.*`, `core.login.link*`, `google-chat.*`, `http.*`; the log lists the missing ones at startup). A UI on the HTTP channel can hide the pending note through `final`'s `pendingNote`, and words the button of a choice with an empty label itself. A channel written for contract 5 isn't loaded by this core, and the log says why: rebuild it against `CHANNEL_API_VERSION` 6.
19
+
20
+ ### Patch Changes
21
+
22
+ - 6bb8291: Needs the framework at 0.54.0 or later, the version that brings the contract it's written for (plugin contract 5, channel contract 6): the peer range on `@mercury-fw/plugin-types` and `@mercury-fw/cli-engine` (the plugins) or `@mercury-fw/channel-types` (the channels) starts there.
23
+
3
24
  ## 0.8.2
4
25
 
5
26
  ### Patch Changes
package/README.md CHANGED
@@ -26,9 +26,13 @@ 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
+ This version is written for version 6 of the channel contract, the one that brings the channels' texts in the person's language, and needs the framework at 0.54 or later: a core with an older contract skips it, and the log says why.
30
+
29
31
  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
32
 
31
- When a tool stages an action that needs confirming, the channel sends a card with the action and a button per choice the tool offers: one `Conferma` for a plain staging (deleting a Jira issue, say), more when there's a choice to make (the web search plugin offers "Solo questa volta" and "Sempre per \<host\>" before reading a page on a host nobody allowed). Clicking a button confirms that choice and the others die with it, and the card shows the choice taken instead of the buttons; typing the token in the conversation does the same, leaving the card as it is. A click that confirms nothing (the request expired, or in a shared space it's someone else's) leaves the card for whoever has to choose. When the confirmed action's result goes back to the model (a page read after the person allowed it), the channel runs the conversation's turn again instead of sending the raw result, so the answer comes from the model, built on what came back.
33
+ Every text the channel shows a person (the cards' titles and status lines, the confirm button, the replies to a confirmation) is in their language: the one they asked the assistant for, else the language of their Chat client when the event carries it, else the app's default. The cards' own texts are `google-chat.` keys an app translates in its language files.
34
+
35
+ When a tool stages an action that needs confirming, the channel sends a card with the action and a button per choice the tool offers: one "Confirm" for a plain staging (deleting a Jira issue, say), more when there's a choice to make (the web search plugin offers "Only this time" and "Always for \<host\>", in the person's language, before reading a page on a host nobody allowed). Clicking a button confirms that choice and the others die with it, and the card shows the choice taken instead of the buttons; typing the token in the conversation does the same, leaving the card as it is. A click that confirms nothing (the request expired, or in a shared space it's someone else's) leaves the card for whoever has to choose. When the confirmed action's result goes back to the model (a page read after the person allowed it), the channel runs the conversation's turn again instead of sending the raw result, so the answer comes from the model, built on what came back.
32
36
 
33
37
  ## Table of contents
34
38
 
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { sendMessage, sendCard, updateCard, getOrCreateDmSpace, type ServiceAccountCredentials, type TokenSource } from "./google-chat-app-client.ts";
23
23
  import { type PubSubSubscription } from "./google-chat-pubsub-stream.ts";
24
- import { type ConfirmOutcome, type Provider, type Principal } from "@mercury-fw/channel-types";
24
+ import { type ChannelI18n, type ChannelRuntimeContext, type Provider } from "@mercury-fw/channel-types";
25
25
  /**
26
26
  * Composite session key: space + sender. Deliberately does NOT include
27
27
  * `thread` — confirmed live (two consecutive messages in the same DM
@@ -51,6 +51,8 @@ export type ParsedMessageEvent = {
51
51
  * live against a real DM event this session.
52
52
  */
53
53
  isDirectMessage: boolean;
54
+ /** The language of the sender's Chat client (`common.userLocale`), when the event carries it: the turn's language hint. */
55
+ locale?: string;
54
56
  };
55
57
  export type ParsedCardClickEvent = {
56
58
  kind: "card-click";
@@ -62,28 +64,35 @@ export type ParsedCardClickEvent = {
62
64
  parameters: Record<string, string>;
63
65
  /** As on a message (see `ParsedMessageEvent.isDirectMessage`): for the turn a click resumes. */
64
66
  isDirectMessage: boolean;
67
+ /** As on a message (see `ParsedMessageEvent.locale`). */
68
+ locale?: string;
65
69
  };
66
70
  /** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
67
71
  export declare function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null;
68
- export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string, isDirectMessage?: boolean, messageName?: string) => Promise<void>;
72
+ export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string, isDirectMessage?: boolean, messageName?: string,
73
+ /** The clicking person's client language (`ParsedCardClickEvent.locale`). */
74
+ locale?: string) => Promise<void>;
69
75
  export type GoogleChatProviderDeps = {
70
76
  credentials: ServiceAccountCredentials;
71
77
  subscription: string;
72
78
  /**
73
79
  * Resolves a confirmation token against the core's shared store, injected by
74
- * the channel loader (`ChannelRuntimeContext.confirm`). Returns the reply to
75
- * send, or `null` when the input wasn't token-shaped. With
80
+ * the channel loader (`ChannelRuntimeContext.confirm`). Returns what to tell
81
+ * the person, in their language, or `null` when the input wasn't
82
+ * token-shaped. With
76
83
  * `resolveConfirmation` below, the channel's whole contact with confirmation:
77
84
  * the store, vault and note-writer stay in the core.
78
85
  */
79
- confirm: (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
86
+ confirm: ChannelRuntimeContext["confirm"];
80
87
  /**
81
88
  * The structured sibling of `confirm` (`ChannelRuntimeContext.resolveConfirmation`).
82
89
  * When it's there, a click resolves through it, and an accepted choice
83
90
  * turns its card into the choice taken; without it, a click goes through
84
91
  * `confirm` and the card stays as it was.
85
92
  */
86
- resolveConfirmation?: (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
93
+ resolveConfirmation?: ChannelRuntimeContext["resolveConfirmation"];
94
+ /** The texts the cards show, in each person's language (`ChannelRuntimeContext.i18n`); English when absent. */
95
+ i18n?: ChannelI18n;
87
96
  /**
88
97
  * Handles a `CARD_CLICKED` event's action parameters. Defaults to resolving
89
98
  * the confirm button's token through `deps.confirm` (the same path a bare
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The Google Chat channel's texts a person sees, in English: the base every
3
+ * instance's language files translate (`ChannelPlugin.messages`). The cards'
4
+ * titles and status lines, the confirm button of a plain staging; the replies
5
+ * and the tool labels are the core's and the plugins'.
6
+ */
7
+ import type { Messages, Translate } from "@mercury-fw/channel-types";
8
+ export declare const GOOGLE_CHAT_MESSAGES: Messages;
9
+ /** These texts in English, for a provider built without the core's (`ChannelI18n`); any other key comes back as itself. */
10
+ export declare const englishChat: Translate;
@@ -35,11 +35,11 @@ import {
35
35
  detectLoginRequired,
36
36
  detectPendingConfirmation,
37
37
  loginLinkText,
38
- PENDING_CONFIRMATION_NOTE,
39
- RESUME_AFTER_CONFIRM,
40
- confirmReply,
41
- type ConfirmOutcome,
42
38
  NO_REPLY,
39
+ type ChannelI18n,
40
+ type ChannelRuntimeContext,
41
+ type ConfirmReply,
42
+ type Translate,
43
43
  type LoginRequest,
44
44
  type PendingConfirmation,
45
45
  type ToolOutcome,
@@ -48,20 +48,22 @@ import {
48
48
  type TurnSink,
49
49
  type Principal,
50
50
  } from "@mercury-fw/channel-types";
51
+ import { englishChat } from "./messages.ts";
51
52
 
52
53
  /**
53
54
  * Builds the confirmation card sent when a step stages an irreversible
54
55
  * command (see `pending-confirmation.ts`), one button per choice it offers
55
- * (a single `Conferma` for a plain staging). Each button's parameters carry
56
- * the token, so a click routes straight into the same `tryConfirm` path
56
+ * (a single "Confirm" for a plain staging, in the person's language: `t`).
57
+ * Each button's parameters carry the token, so a click routes straight into
58
+ * the same confirmation path
57
59
  * a bare token typed on the terminal uses (see `onCardClick`'s `confirm`
58
60
  * case, below) — the user never has to see or type the token themselves —
59
61
  * plus its label and the summary, so the click can turn the card into
60
62
  * `buildChosenCard` without the channel keeping any state.
61
63
  */
62
- function buildConfirmCard(pending: PendingConfirmation): ChatCard {
64
+ function buildConfirmCard(pending: PendingConfirmation, t: Translate): ChatCard {
63
65
  return {
64
- header: { title: "Conferma richiesta" },
66
+ header: { title: t("google-chat.confirm.title") },
65
67
  sections: [
66
68
  {
67
69
  widgets: [
@@ -69,13 +71,13 @@ function buildConfirmCard(pending: PendingConfirmation): ChatCard {
69
71
  {
70
72
  buttonList: {
71
73
  buttons: pending.choices.map((choice) => ({
72
- text: choice.label,
74
+ text: choice.label || t("google-chat.confirm.button"),
73
75
  onClick: {
74
76
  action: {
75
77
  function: "confirm",
76
78
  parameters: [
77
79
  { key: "token", value: choice.token },
78
- { key: "label", value: choice.label },
80
+ { key: "label", value: choice.label || t("google-chat.confirm.button") },
79
81
  { key: "summary", value: pending.summary },
80
82
  ],
81
83
  },
@@ -90,14 +92,14 @@ function buildConfirmCard(pending: PendingConfirmation): ChatCard {
90
92
  }
91
93
 
92
94
  /** The confirmation card once a choice is taken: the same request, the choice instead of the buttons. */
93
- function buildChosenCard(summary: string, label: string | undefined): ChatCard {
95
+ function buildChosenCard(summary: string, label: string | undefined, t: Translate): ChatCard {
94
96
  return {
95
- header: { title: "Conferma richiesta" },
97
+ header: { title: t("google-chat.confirm.title") },
96
98
  sections: [
97
99
  {
98
100
  widgets: [
99
101
  { textParagraph: { text: `\`${summary}\`` } },
100
- { textParagraph: { text: label === undefined ? "Scelta registrata." : `Scelto: ${label}` } },
102
+ { textParagraph: { text: label === undefined ? t("google-chat.confirm.taken") : t("google-chat.confirm.chosen", { label }) } },
101
103
  ],
102
104
  },
103
105
  ],
@@ -105,18 +107,16 @@ function buildChosenCard(summary: string, label: string | undefined): ChatCard {
105
107
  }
106
108
 
107
109
  /** 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. */
108
- function buildLoginCard(login: LoginRequest): ChatCard {
110
+ function buildLoginCard(login: LoginRequest, t: Translate): ChatCard {
109
111
  return {
110
- sections: [{ widgets: [{ buttonList: { buttons: [{ text: loginLinkText(login), onClick: { openLink: { url: login.url } } }] } }] }],
112
+ sections: [{ widgets: [{ buttonList: { buttons: [{ text: loginLinkText(login, t), onClick: { openLink: { url: login.url } } }] } }] }],
111
113
  };
112
114
  }
113
115
 
114
- const TOOL_STATUS_LINES: Record<"loading" | ToolOutcome, string> = {
115
- loading: "In corso…",
116
- success: "Fatto.",
117
- failed: "Non riuscito.",
118
- pending: "In attesa di conferma.",
119
- };
116
+ /** A card's status line, in the person's language. */
117
+ function statusLine(status: "loading" | ToolOutcome, t: Translate): string {
118
+ return t(`google-chat.status.${status}`);
119
+ }
120
120
 
121
121
  /**
122
122
  * A tool-call/reasoning status card: `label` is a section `header`
@@ -129,10 +129,10 @@ const TOOL_STATUS_LINES: Record<"loading" | ToolOutcome, string> = {
129
129
  * message.
130
130
  *
131
131
  * The status is appended to the title too, not just left inside the
132
- * collapsed body: a title stuck reading "Sto leggendo dati con jira…"
133
- * (present progressive) forever, even once the card's own body says
134
- * "Fatto.", looked like the card never updated at all — confirmed live
135
- * (the body did patch, only the title didn't change).
132
+ * collapsed body: a title stuck reading "Reading Jira…" (present
133
+ * progressive) forever, even once the card's own body says "Done.", looked
134
+ * like the card never updated at all — confirmed live (the body did patch,
135
+ * only the title didn't change).
136
136
  *
137
137
  * Native `collapsible` state is client-side only and resets to collapsed
138
138
  * on every PATCH — confirmed live. Harmless for a card patched exactly
@@ -146,14 +146,14 @@ const TOOL_STATUS_LINES: Record<"loading" | ToolOutcome, string> = {
146
146
  * nothing patches the message again, so the native toggle can never be
147
147
  * reset out from under the user again either.
148
148
  */
149
- function buildToolCallCard(label: string, detail: string, status: "loading" | ToolOutcome): ChatCard {
149
+ function buildToolCallCard(label: string, detail: string, status: "loading" | ToolOutcome, t: Translate): ChatCard {
150
150
  return {
151
151
  sections: [
152
152
  {
153
- header: status === "loading" ? label : `${label} ${TOOL_STATUS_LINES[status]}`,
153
+ header: status === "loading" ? label : `${label} ${statusLine(status, t)}`,
154
154
  collapsible: true,
155
155
  uncollapsibleWidgetsCount: 0,
156
- widgets: [{ textParagraph: { text: detail } }, { textParagraph: { text: TOOL_STATUS_LINES[status] } }],
156
+ widgets: [{ textParagraph: { text: detail } }, { textParagraph: { text: statusLine(status, t) } }],
157
157
  },
158
158
  ],
159
159
  };
@@ -171,7 +171,7 @@ const REASONING_TAIL_CHARS = 4000;
171
171
  * any intermediate loading/prefill event to distinguish finer-grained
172
172
  * states here (checked against the API docs) — `load_duration`/
173
173
  * `prompt_eval_duration` only appear in the final, non-streamed response.
174
- * Header is the generic "Stato" (not a restatement of the body) so this
174
+ * Header is the generic "Status" (not a restatement of the body) so this
175
175
  * has somewhere to grow if a genuinely different state ever becomes
176
176
  * observable, rather than needing a redesign.
177
177
  *
@@ -180,12 +180,12 @@ const REASONING_TAIL_CHARS = 4000;
180
180
  * (confirmed live — a thin grey line, no visible text at all), so this
181
181
  * always carries at least one `textParagraph`, same as every other card
182
182
  * in this file. If the turn's first reasoning burst arrives,
183
- * `onReasoningChunk` patches this exact card into the "Sto pensando…"
183
+ * `onReasoningChunk` patches this exact card into the "Thinking…"
184
184
  * card in place, rather than sending a second message; otherwise it's
185
185
  * simply left as the last status before the turn's real answer.
186
186
  */
187
- function buildReceivingCard(): ChatCard {
188
- return { sections: [{ header: "Stato", widgets: [{ textParagraph: { text: "Messaggio in ricezione…" } }] }] };
187
+ function buildReceivingCard(t: Translate): ChatCard {
188
+ return { sections: [{ header: t("google-chat.receiving.title"), widgets: [{ textParagraph: { text: t("google-chat.receiving.text") } }] }] };
189
189
  }
190
190
 
191
191
  /**
@@ -226,6 +226,8 @@ type RawChatEvent = {
226
226
  action?: { actionMethodName?: string; parameters?: Array<{ key?: string; value?: string }> };
227
227
  space?: { name?: string; type?: string };
228
228
  user?: RawChatSender;
229
+ /** What the person's Chat client says about them; `userLocale` is their language (`en-US`), when the event carries it. */
230
+ common?: { userLocale?: string };
229
231
  };
230
232
 
231
233
  export type ParsedMessageEvent = {
@@ -245,6 +247,8 @@ export type ParsedMessageEvent = {
245
247
  * live against a real DM event this session.
246
248
  */
247
249
  isDirectMessage: boolean;
250
+ /** The language of the sender's Chat client (`common.userLocale`), when the event carries it: the turn's language hint. */
251
+ locale?: string;
248
252
  };
249
253
 
250
254
  export type ParsedCardClickEvent = {
@@ -257,8 +261,16 @@ export type ParsedCardClickEvent = {
257
261
  parameters: Record<string, string>;
258
262
  /** As on a message (see `ParsedMessageEvent.isDirectMessage`): for the turn a click resumes. */
259
263
  isDirectMessage: boolean;
264
+ /** As on a message (see `ParsedMessageEvent.locale`). */
265
+ locale?: string;
260
266
  };
261
267
 
268
+ /** The language the event says the person's client is in, when it says. */
269
+ function localeOf(event: RawChatEvent | null | undefined): string | undefined {
270
+ const locale = event?.common?.userLocale;
271
+ return typeof locale === "string" && locale.trim() !== "" ? locale.trim() : undefined;
272
+ }
273
+
262
274
  /** The email Google vouches for on `sender`: a person's only (`HUMAN`), never a bot's or an unknown kind's. */
263
275
  function emailOf(sender: RawChatSender | undefined): string | undefined {
264
276
  return typeof sender?.email === "string" && sender.email !== "" && sender.type === "HUMAN" ? sender.email : undefined;
@@ -267,6 +279,7 @@ function emailOf(sender: RawChatSender | undefined): string | undefined {
267
279
  /** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
268
280
  export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null {
269
281
  const event = raw as RawChatEvent;
282
+ const locale = localeOf(event);
270
283
  if (event?.type === "MESSAGE") {
271
284
  const m = event.message;
272
285
  if (!m || typeof m.name !== "string" || typeof m.text !== "string") return null;
@@ -281,6 +294,7 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
281
294
  senderDisplayName: m.sender.displayName,
282
295
  senderEmail: emailOf(m.sender),
283
296
  isDirectMessage: m.space.type === "DM",
297
+ ...(locale === undefined ? {} : { locale }),
284
298
  };
285
299
  }
286
300
  if (event?.type === "CARD_CLICKED") {
@@ -300,6 +314,7 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
300
314
  senderEmail: emailOf(event.user),
301
315
  parameters,
302
316
  isDirectMessage: event.space.type === "DM",
317
+ ...(locale === undefined ? {} : { locale }),
303
318
  };
304
319
  }
305
320
  return null;
@@ -312,6 +327,8 @@ export type CardClickHandler = (
312
327
  senderEmail?: string,
313
328
  isDirectMessage?: boolean,
314
329
  messageName?: string,
330
+ /** The clicking person's client language (`ParsedCardClickEvent.locale`). */
331
+ locale?: string,
315
332
  ) => Promise<void>;
316
333
 
317
334
  export type GoogleChatProviderDeps = {
@@ -319,19 +336,22 @@ export type GoogleChatProviderDeps = {
319
336
  subscription: string;
320
337
  /**
321
338
  * Resolves a confirmation token against the core's shared store, injected by
322
- * the channel loader (`ChannelRuntimeContext.confirm`). Returns the reply to
323
- * send, or `null` when the input wasn't token-shaped. With
339
+ * the channel loader (`ChannelRuntimeContext.confirm`). Returns what to tell
340
+ * the person, in their language, or `null` when the input wasn't
341
+ * token-shaped. With
324
342
  * `resolveConfirmation` below, the channel's whole contact with confirmation:
325
343
  * the store, vault and note-writer stay in the core.
326
344
  */
327
- confirm: (token: string, sessionKey: string, principal: Principal) => Promise<string | null>;
345
+ confirm: ChannelRuntimeContext["confirm"];
328
346
  /**
329
347
  * The structured sibling of `confirm` (`ChannelRuntimeContext.resolveConfirmation`).
330
348
  * When it's there, a click resolves through it, and an accepted choice
331
349
  * turns its card into the choice taken; without it, a click goes through
332
350
  * `confirm` and the card stays as it was.
333
351
  */
334
- resolveConfirmation?: (token: string, sessionKey: string, principal: Principal) => Promise<ConfirmOutcome>;
352
+ resolveConfirmation?: ChannelRuntimeContext["resolveConfirmation"];
353
+ /** The texts the cards show, in each person's language (`ChannelRuntimeContext.i18n`); English when absent. */
354
+ i18n?: ChannelI18n;
335
355
  /**
336
356
  * Handles a `CARD_CLICKED` event's action parameters. Defaults to resolving
337
357
  * the confirm button's token through `deps.confirm` (the same path a bare
@@ -366,6 +386,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
366
386
  const subscriptionFn = deps.subscriptionFn ?? openSubscription;
367
387
  const clientDeps = { tokenSource };
368
388
  const sentMessageNames = new Set<string>();
389
+ /** The texts for `principal`, on the client language `hint`. */
390
+ const textsFor = async (principal: Principal, hint: string | undefined): Promise<Translate> =>
391
+ deps.i18n ? (await deps.i18n.forRequest(principal, hint)).t : englishChat;
369
392
  // Per-session serialization: pollOnce's setInterval fires on a fixed
370
393
  // clock regardless of whether the previous tick's turn finished, so two
371
394
  // overlapping ticks could otherwise both call handleTurn for the SAME
@@ -399,9 +422,16 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
399
422
  }
400
423
  }
401
424
 
402
- /** Resumes the session's turn after a confirmation whose result went back to the model (`RESUME_AFTER_CONFIRM`). */
403
- function resumeTurn(handleTurn: HandleTurn, space: string, principal: Principal, isDirectMessage: boolean): Promise<void> {
425
+ /** Resumes the session's turn after a confirmation whose result went back to the model (`ConfirmReply.resume`). */
426
+ async function resumeTurn(
427
+ handleTurn: HandleTurn,
428
+ space: string,
429
+ principal: Principal,
430
+ isDirectMessage: boolean,
431
+ locale: string | undefined,
432
+ ): Promise<void> {
404
433
  const sessionKey = deriveSessionKey(space, principal.id);
434
+ const t = await textsFor(principal, locale);
405
435
  return runInSession(sessionKey, () =>
406
436
  handleTurn(
407
437
  {
@@ -412,8 +442,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
412
442
  principal,
413
443
  logPrefix: `[chat:${space}:${principal.id}] `,
414
444
  resumeAfterConfirm: true,
445
+ ...(locale === undefined ? {} : { languageHint: locale }),
415
446
  },
416
- createSink(space, { user: principal.id, isDirectMessage }),
447
+ createSink(space, { user: principal.id, isDirectMessage }, t),
417
448
  ),
418
449
  );
419
450
  }
@@ -429,7 +460,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
429
460
  */
430
461
  const onCardClick: CardClickHandler =
431
462
  deps.onCardClick ??
432
- (async (params, space, sender, senderEmail, isDirectMessage = false, messageName) => {
463
+ (async (params, space, sender, senderEmail, isDirectMessage = false, messageName, locale) => {
433
464
  const token = params.token;
434
465
  if (!token) {
435
466
  log(`[chat] card click with no token parameter`);
@@ -441,13 +472,14 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
441
472
  ...(senderEmail === undefined ? {} : { claims: { email: senderEmail } }),
442
473
  };
443
474
  const sessionKey = deriveSessionKey(space, sender);
444
- let reply: string | null;
475
+ let reply: ConfirmReply | null;
445
476
  if (deps.resolveConfirmation) {
446
- const outcome = await deps.resolveConfirmation(token, sessionKey, principal);
477
+ const outcome = await deps.resolveConfirmation(token, sessionKey, principal, locale);
447
478
  const accepted = outcome.status === "ok" || outcome.status === "failed" || outcome.status === "resume";
448
479
  if (accepted && messageName !== undefined && params.summary !== undefined) {
449
480
  try {
450
- await updateCardFn(messageName, buildChosenCard(params.summary, params.label), clientDeps);
481
+ const t = await textsFor(principal, locale);
482
+ await updateCardFn(messageName, buildChosenCard(params.summary, params.label, t), clientDeps);
451
483
  log(`[chat:${space}] [out] confirm card ${messageName} patched to the choice taken`);
452
484
  } catch (err) {
453
485
  log(`[chat:${space}] confirm card patch failed: ${String(err)}`);
@@ -456,17 +488,19 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
456
488
  // On not-found nothing was taken, so `confirm` gives the reply as it
457
489
  // always did: the core's own message for someone it won't talk to,
458
490
  // which the outcome doesn't carry.
459
- reply = outcome.status === "not-found" ? await deps.confirm(token, sessionKey, principal) : confirmReply(outcome);
491
+ if (outcome.status === "not-found") reply = await deps.confirm(token, sessionKey, principal, locale);
492
+ else if (outcome.text === null) reply = null;
493
+ else reply = outcome.status === "resume" ? { text: outcome.text, resume: true } : { text: outcome.text };
460
494
  } else {
461
- reply = await deps.confirm(token, sessionKey, principal);
495
+ reply = await deps.confirm(token, sessionKey, principal, locale);
462
496
  }
463
- if (reply === RESUME_AFTER_CONFIRM && activeHandleTurn) {
464
- await resumeTurn(activeHandleTurn, space, principal, isDirectMessage);
497
+ if (reply?.resume && activeHandleTurn) {
498
+ await resumeTurn(activeHandleTurn, space, principal, isDirectMessage, locale);
465
499
  return;
466
500
  }
467
501
  if (reply !== null) {
468
- log(`[chat:${space}] [out] ${reply}`);
469
- const sent = await sendMessageFn(space, reply, clientDeps);
502
+ log(`[chat:${space}] [out] ${reply.text}`);
503
+ const sent = await sendMessageFn(space, reply.text, clientDeps);
470
504
  sentMessageNames.add(sent.name);
471
505
  }
472
506
  });
@@ -476,8 +510,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
476
510
  * Per-turn output sink — same responsibilities as the retired `ChatStreamer`,
477
511
  * rebuilt against the new client. `viewer` is the person the turn is for;
478
512
  * outside a direct message, what only they may see is sent to them alone.
513
+ * `t` gives the cards' texts in their language.
479
514
  */
480
- function createSink(space: string, viewer: { user: string; isDirectMessage: boolean }): TurnSink {
515
+ function createSink(space: string, viewer: { user: string; isDirectMessage: boolean }, t: Translate): TurnSink {
481
516
  let chain: Promise<void> = Promise.resolve();
482
517
 
483
518
  function enqueue(fn: () => Promise<void>): void {
@@ -519,7 +554,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
519
554
  const shownLogins = new Set<string>();
520
555
  enqueue(async () => {
521
556
  log(`[chat:${space}] [out] receiving card`);
522
- const sent = await sendCardFn(space, buildReceivingCard(), clientDeps);
557
+ const sent = await sendCardFn(space, buildReceivingCard(t), clientDeps);
523
558
  sentMessageNames.add(sent.name);
524
559
  receivingCardName = sent.name;
525
560
  });
@@ -531,7 +566,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
531
566
  return;
532
567
  }
533
568
  enqueue(async () => {
534
- const card = buildToolCallCard(label, detail ?? "", "loading");
569
+ const card = buildToolCallCard(label, detail ?? "", "loading", t);
535
570
  log(`[chat:${space}] [out] tool card: ${label}`);
536
571
  const sent = await sendCardFn(space, card, clientDeps);
537
572
  sentMessageNames.add(sent.name);
@@ -545,7 +580,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
545
580
  log(`[chat:${space}] onToolFinish for unknown toolCallId ${toolCallId}`);
546
581
  return;
547
582
  }
548
- const card = buildToolCallCard(entry.label, entry.detail, outcome);
583
+ const card = buildToolCallCard(entry.label, entry.detail, outcome, t);
549
584
  log(`[chat:${space}] [out] patching ${entry.name} to "${outcome}"`);
550
585
  const patched = await updateCardFn(entry.name, card, clientDeps);
551
586
  log(`[chat:${space}] [out] patched ${patched.name}`);
@@ -562,9 +597,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
562
597
  entry = { name: undefined, buffer: chunk };
563
598
  reasoningCards.set(id, entry);
564
599
  enqueue(async () => {
565
- const card = buildToolCallCard("Sto pensando…", "", "loading");
600
+ const card = buildToolCallCard(t("google-chat.thinking"), "", "loading", t);
566
601
  if (receivingCardName !== undefined) {
567
- // Replace the "Stato" placeholder in place instead of
602
+ // Replace the "Status" placeholder in place instead of
568
603
  // sending a second message.
569
604
  log(`[chat:${space}] [out] reasoning card (${id}) — replacing receiving card`);
570
605
  const patched = await updateCardFn(receivingCardName, card, clientDeps);
@@ -591,7 +626,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
591
626
  enqueue(async () => {
592
627
  if (entry.name === undefined) return; // shouldn't happen given enqueue's own FIFO ordering, but stays defensive
593
628
  const status = failed ? "failed" : "success";
594
- const card = buildToolCallCard("Sto pensando…", tailTruncate(entry.buffer, REASONING_TAIL_CHARS), status);
629
+ const card = buildToolCallCard(t("google-chat.thinking"), tailTruncate(entry.buffer, REASONING_TAIL_CHARS), status, t);
595
630
  log(`[chat:${space}] [out] reasoning patch (${id}): status=${status} bufferLen=${entry.buffer.length}`);
596
631
  await updateCardFn(entry.name, card, clientDeps);
597
632
  reasoningCards.delete(id);
@@ -607,7 +642,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
607
642
  if (pending) {
608
643
  enqueue(async () => {
609
644
  log(`[chat:${space}] [out] confirm card: ${pending.summary}`);
610
- const sent = await sendCardFn(space, buildConfirmCard(pending), clientDeps);
645
+ const sent = await sendCardFn(space, buildConfirmCard(pending, t), clientDeps);
611
646
  sentMessageNames.add(sent.name);
612
647
  });
613
648
  }
@@ -617,14 +652,18 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
617
652
  enqueue(async () => {
618
653
  log(`[chat:${space}] [out] login card: ${login.service}`);
619
654
  const options = viewer.isDirectMessage ? undefined : { privateViewer: viewer.user };
620
- const sent = await sendCardFn(space, buildLoginCard(login), clientDeps, options);
655
+ const sent = await sendCardFn(space, buildLoginCard(login, t), clientDeps, options);
621
656
  sentMessageNames.add(sent.name);
622
657
  });
623
658
  }
624
659
  },
625
- finalize: async (finalText: string) => {
626
- const trimmed = finalText.trim();
627
- if (trimmed.length > 0 && trimmed !== NO_REPLY && trimmed !== PENDING_CONFIRMATION_NOTE) {
660
+ finalize: async (finalText, info) => {
661
+ // The core's stand-in for an answer the model didn't write: the
662
+ // confirmation card already says it; only a list after it is sent.
663
+ const note = info?.pendingNote;
664
+ const shown = note !== undefined && finalText.startsWith(note) ? finalText.slice(note.length) : finalText;
665
+ const trimmed = shown.trim();
666
+ if (trimmed.length > 0 && trimmed !== NO_REPLY) {
628
667
  for (const message of splitForSendLimit(trimmed)) {
629
668
  sendPlain(message);
630
669
  }
@@ -651,19 +690,20 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
651
690
  ...(event.senderEmail === undefined ? {} : { claims: { email: event.senderEmail } }),
652
691
  };
653
692
 
654
- const confirmReply = await deps.confirm(event.text, sessionKey, principal);
655
- if (confirmReply === RESUME_AFTER_CONFIRM) {
656
- await resumeTurn(handleTurn, event.space, principal, event.isDirectMessage);
693
+ const reply = await deps.confirm(event.text, sessionKey, principal, event.locale);
694
+ if (reply?.resume) {
695
+ await resumeTurn(handleTurn, event.space, principal, event.isDirectMessage, event.locale);
657
696
  return;
658
697
  }
659
- if (confirmReply !== null) {
660
- log(`[chat:${event.space}] [out] ${confirmReply}`);
661
- const sent = await sendMessageFn(event.space, confirmReply, clientDeps);
698
+ if (reply !== null) {
699
+ log(`[chat:${event.space}] [out] ${reply.text}`);
700
+ const sent = await sendMessageFn(event.space, reply.text, clientDeps);
662
701
  sentMessageNames.add(sent.name);
663
702
  return;
664
703
  }
665
704
 
666
- // Only the actual model turn needs serializing — tryConfirm above
705
+ const t = await textsFor(principal, event.locale);
706
+ // Only the actual model turn needs serializing — the confirmation above
667
707
  // never touches SessionHistory, so it's always safe to run right
668
708
  // away regardless of whether this session is mid-turn.
669
709
  await runInSession(sessionKey, () =>
@@ -675,8 +715,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
675
715
  sessionKey,
676
716
  principal,
677
717
  logPrefix: `[chat:${event.space}:${event.sender}] `,
718
+ ...(event.locale === undefined ? {} : { languageHint: event.locale }),
678
719
  },
679
- createSink(event.space, { user: event.sender, isDirectMessage: event.isDirectMessage }),
720
+ createSink(event.space, { user: event.sender, isDirectMessage: event.isDirectMessage }, t),
680
721
  ),
681
722
  );
682
723
  }
@@ -709,7 +750,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
709
750
  if (parsed.kind === "message") {
710
751
  await processMessageEvent(parsed, handleTurn);
711
752
  } else {
712
- await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail, parsed.isDirectMessage, parsed.messageName);
753
+ await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail, parsed.isDirectMessage, parsed.messageName, parsed.locale);
713
754
  }
714
755
  } catch (err) {
715
756
  log(`[chat] event handling failed: ${String(err)}`);
package/index.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import type { ChannelPlugin, ChannelRuntimeContext } from "@mercury-fw/channel-types";
11
11
  import { createGoogleChatProvider } from "./google-chat-provider.ts";
12
+ import { GOOGLE_CHAT_MESSAGES } from "./messages.ts";
12
13
 
13
14
  /** Reads a required env var, failing loudly (caught by the channel loader) instead of silently degrading. */
14
15
  function require(env: ChannelRuntimeContext["env"], name: string): string {
@@ -22,8 +23,9 @@ function require(env: ChannelRuntimeContext["env"], name: string): string {
22
23
  export const googleChatChannel: ChannelPlugin = {
23
24
  // The contract this channel is written for, as a literal: importing
24
25
  // CHANNEL_API_VERSION would report whichever contract is installed.
25
- apiVersion: 5,
26
+ apiVersion: 6,
26
27
  name: "google-chat",
28
+ messages: GOOGLE_CHAT_MESSAGES,
27
29
  build: (ctx) => {
28
30
  // No subscription configured ⇒ this instance simply doesn't run Google Chat.
29
31
  const subscription = ctx.env.GOOGLE_CHAT_PUBSUB_SUBSCRIPTION;
@@ -40,6 +42,7 @@ export const googleChatChannel: ChannelPlugin = {
40
42
  subscription,
41
43
  confirm: ctx.confirm,
42
44
  ...(ctx.resolveConfirmation === undefined ? {} : { resolveConfirmation: ctx.resolveConfirmation }),
45
+ i18n: ctx.i18n,
43
46
  log: ctx.log,
44
47
  });
45
48
  },
package/messages.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The Google Chat channel's texts a person sees, in English: the base every
3
+ * instance's language files translate (`ChannelPlugin.messages`). The cards'
4
+ * titles and status lines, the confirm button of a plain staging; the replies
5
+ * and the tool labels are the core's and the plugins'.
6
+ */
7
+ import type { Messages, Translate } from "@mercury-fw/channel-types";
8
+
9
+ export const GOOGLE_CHAT_MESSAGES: Messages = {
10
+ // The confirmation card (`buildConfirmCard`, `buildChosenCard`).
11
+ "google-chat.confirm.title": "Confirmation needed",
12
+ "google-chat.confirm.button": "Confirm",
13
+ "google-chat.confirm.chosen": "Chosen: {label}",
14
+ "google-chat.confirm.taken": "Choice recorded.",
15
+ // A tool call's or a reasoning burst's card (`buildToolCallCard`).
16
+ "google-chat.status.loading": "In progress…",
17
+ "google-chat.status.success": "Done.",
18
+ "google-chat.status.failed": "Failed.",
19
+ "google-chat.status.pending": "Waiting for confirmation.",
20
+ "google-chat.thinking": "Thinking…",
21
+ // The card sent as soon as a message arrives (`buildReceivingCard`).
22
+ "google-chat.receiving.title": "Status",
23
+ "google-chat.receiving.text": "Message received…",
24
+ };
25
+
26
+ /** These texts in English, for a provider built without the core's (`ChannelI18n`); any other key comes back as itself. */
27
+ export const englishChat: Translate = (key, params) =>
28
+ (GOOGLE_CHAT_MESSAGES[key] ?? key).replace(/\{(\w+)\}/g, (whole, name: string) =>
29
+ params !== undefined && Object.hasOwn(params, name) ? String(params[name]) : whole,
30
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-google-chat",
3
- "version": "0.8.2",
3
+ "version": "0.9.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.53.1 <1.0.0"
34
+ "@mercury-fw/channel-types": ">=0.54.0 <1.0.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@mercury-fw/channel-types": "0.53.1",
37
+ "@mercury-fw/channel-types": "0.54.0",
38
38
  "@mercury-fw/typescript-config": "*",
39
39
  "@types/bun": "^1.4.2",
40
40
  "typescript": "^6.0.3"