@mercury-fw/channel-google-chat 0.8.2 → 0.10.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 +38 -0
- package/README.md +5 -1
- package/dist/google-chat-provider.d.ts +21 -6
- package/dist/messages.d.ts +10 -0
- package/google-chat-provider.ts +129 -70
- package/index.ts +4 -1
- package/messages.ts +30 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# @mercury-fw/channel-google-chat
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1126106: Every turn tells the model when and where the person wrote it.
|
|
8
|
+
|
|
9
|
+
- Each message reaches the model after a line with the weekday, date, time and time zone it was written in (`[Wednesday 2026-10-21 14:32 Europe/Rome]`), and the system prompt says what that line is. The conversation's archive and what a UI shows of it keep the message as written.
|
|
10
|
+
- A resumed conversation's messages come back stamped from the archive's timestamps.
|
|
11
|
+
- The Layer 1 summary keeps when things were said; the episodic summary quotes a date only from those lines, never guessing one. The semantic-fact extractor and the capture card's preview leave the line out.
|
|
12
|
+
- The time zone, always an IANA name (`Europe/Rome`), is the one the person saved, else the one their client is in when the channel passes it on, else the new `i18n.timeZone` in `mercury.config.ts`, else the machine's. A `timeZone` that isn't a zone's name stops the start.
|
|
13
|
+
- The person saves theirs by telling the assistant (the new `set_time_zone` tool), or from a channel's settings.
|
|
14
|
+
- Channel contract: `InboundTurn.timeZoneHint`, and `ctx.settings.setTimeZone` for a channel with a settings area; `reads.me` also returns the saved `timeZone`. Both are optional additions: the contract version stays 6.
|
|
15
|
+
- HTTP channel: the `X-Time-Zone` header on `POST /turn` (allowed by CORS), `timeZone` in `GET /me`, and `PATCH /me` with `{ timeZone }` to save it. Needs the framework at 0.56 or later; an older core ignores the header and offers no `PATCH /me`.
|
|
16
|
+
- Google Chat channel: the client's time zone (`common.timeZone`), when the event carries it, on messages and on the turn a click resumes.
|
|
17
|
+
|
|
18
|
+
To set a default for an app's people, add `timeZone: "Europe/Rome"` (their zone) to `i18n` in `mercury.config.ts`; without it the machine's zone is used, often UTC inside a container.
|
|
19
|
+
|
|
20
|
+
## 0.9.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- e00ff73: The channels' texts come out in the person's language too, and the language their client asks for counts. Channel contract version 6:
|
|
25
|
+
|
|
26
|
+
- 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.
|
|
27
|
+
- 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)`.
|
|
28
|
+
- 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).
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
- 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.
|
|
32
|
+
- 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.
|
|
33
|
+
- The terminal's replies to a confirmation read in the terminal person's language, like its other lines.
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
### Patch Changes
|
|
38
|
+
|
|
39
|
+
- 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.
|
|
40
|
+
|
|
3
41
|
## 0.8.2
|
|
4
42
|
|
|
5
43
|
### 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. The client's time zone reaches the model from the framework at 0.56 on; an older core ignores it.
|
|
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
|
-
|
|
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. The time zone of their Chat client, when the event carries it (`common.timeZone`), goes to the core the same way: the model reads each message with the time there, unless the person saved a zone of their own.
|
|
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
|
|
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,10 @@ 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;
|
|
56
|
+
/** The time zone of the sender's Chat client (`common.timeZone.id`), when the event carries it: the turn's time zone hint. */
|
|
57
|
+
timeZone?: string;
|
|
54
58
|
};
|
|
55
59
|
export type ParsedCardClickEvent = {
|
|
56
60
|
kind: "card-click";
|
|
@@ -62,28 +66,39 @@ export type ParsedCardClickEvent = {
|
|
|
62
66
|
parameters: Record<string, string>;
|
|
63
67
|
/** As on a message (see `ParsedMessageEvent.isDirectMessage`): for the turn a click resumes. */
|
|
64
68
|
isDirectMessage: boolean;
|
|
69
|
+
/** As on a message (see `ParsedMessageEvent.locale`). */
|
|
70
|
+
locale?: string;
|
|
71
|
+
/** As on a message (see `ParsedMessageEvent.timeZone`). */
|
|
72
|
+
timeZone?: string;
|
|
65
73
|
};
|
|
66
74
|
/** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
|
|
67
75
|
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
|
|
76
|
+
export type CardClickHandler = (params: Record<string, string>, space: string, sender: string, senderEmail?: string, isDirectMessage?: boolean, messageName?: string,
|
|
77
|
+
/** The clicking person's client language (`ParsedCardClickEvent.locale`). */
|
|
78
|
+
locale?: string,
|
|
79
|
+
/** The clicking person's client time zone (`ParsedCardClickEvent.timeZone`). */
|
|
80
|
+
timeZone?: string) => Promise<void>;
|
|
69
81
|
export type GoogleChatProviderDeps = {
|
|
70
82
|
credentials: ServiceAccountCredentials;
|
|
71
83
|
subscription: string;
|
|
72
84
|
/**
|
|
73
85
|
* Resolves a confirmation token against the core's shared store, injected by
|
|
74
|
-
* the channel loader (`ChannelRuntimeContext.confirm`). Returns
|
|
75
|
-
*
|
|
86
|
+
* the channel loader (`ChannelRuntimeContext.confirm`). Returns what to tell
|
|
87
|
+
* the person, in their language, or `null` when the input wasn't
|
|
88
|
+
* token-shaped. With
|
|
76
89
|
* `resolveConfirmation` below, the channel's whole contact with confirmation:
|
|
77
90
|
* the store, vault and note-writer stay in the core.
|
|
78
91
|
*/
|
|
79
|
-
confirm:
|
|
92
|
+
confirm: ChannelRuntimeContext["confirm"];
|
|
80
93
|
/**
|
|
81
94
|
* The structured sibling of `confirm` (`ChannelRuntimeContext.resolveConfirmation`).
|
|
82
95
|
* When it's there, a click resolves through it, and an accepted choice
|
|
83
96
|
* turns its card into the choice taken; without it, a click goes through
|
|
84
97
|
* `confirm` and the card stays as it was.
|
|
85
98
|
*/
|
|
86
|
-
resolveConfirmation?:
|
|
99
|
+
resolveConfirmation?: ChannelRuntimeContext["resolveConfirmation"];
|
|
100
|
+
/** The texts the cards show, in each person's language (`ChannelRuntimeContext.i18n`); English when absent. */
|
|
101
|
+
i18n?: ChannelI18n;
|
|
87
102
|
/**
|
|
88
103
|
* Handles a `CARD_CLICKED` event's action parameters. Defaults to resolving
|
|
89
104
|
* 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;
|
package/google-chat-provider.ts
CHANGED
|
@@ -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
|
|
56
|
-
* the token, so a click routes straight into
|
|
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: "
|
|
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: "
|
|
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 ? "
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 "
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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} ${
|
|
153
|
+
header: status === "loading" ? label : `${label} ${statusLine(status, t)}`,
|
|
154
154
|
collapsible: true,
|
|
155
155
|
uncollapsibleWidgetsCount: 0,
|
|
156
|
-
widgets: [{ textParagraph: { text: detail } }, { textParagraph: { text:
|
|
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 "
|
|
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 "
|
|
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: "
|
|
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, when the event carries it: `userLocale` is their language (`en-US`), `timeZone.id` the zone they're in (`Europe/Rome`). */
|
|
230
|
+
common?: { userLocale?: string; timeZone?: { id?: string; offset?: number } };
|
|
229
231
|
};
|
|
230
232
|
|
|
231
233
|
export type ParsedMessageEvent = {
|
|
@@ -245,6 +247,10 @@ 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;
|
|
252
|
+
/** The time zone of the sender's Chat client (`common.timeZone.id`), when the event carries it: the turn's time zone hint. */
|
|
253
|
+
timeZone?: string;
|
|
248
254
|
};
|
|
249
255
|
|
|
250
256
|
export type ParsedCardClickEvent = {
|
|
@@ -257,8 +263,24 @@ export type ParsedCardClickEvent = {
|
|
|
257
263
|
parameters: Record<string, string>;
|
|
258
264
|
/** As on a message (see `ParsedMessageEvent.isDirectMessage`): for the turn a click resumes. */
|
|
259
265
|
isDirectMessage: boolean;
|
|
266
|
+
/** As on a message (see `ParsedMessageEvent.locale`). */
|
|
267
|
+
locale?: string;
|
|
268
|
+
/** As on a message (see `ParsedMessageEvent.timeZone`). */
|
|
269
|
+
timeZone?: string;
|
|
260
270
|
};
|
|
261
271
|
|
|
272
|
+
/** The language the event says the person's client is in, when it says. */
|
|
273
|
+
function localeOf(event: RawChatEvent | null | undefined): string | undefined {
|
|
274
|
+
const locale = event?.common?.userLocale;
|
|
275
|
+
return typeof locale === "string" && locale.trim() !== "" ? locale.trim() : undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The time zone the event says the person's client is in, when it says. */
|
|
279
|
+
function timeZoneOf(event: RawChatEvent | null | undefined): string | undefined {
|
|
280
|
+
const id = event?.common?.timeZone?.id;
|
|
281
|
+
return typeof id === "string" && id.trim() !== "" ? id.trim() : undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
262
284
|
/** The email Google vouches for on `sender`: a person's only (`HUMAN`), never a bot's or an unknown kind's. */
|
|
263
285
|
function emailOf(sender: RawChatSender | undefined): string | undefined {
|
|
264
286
|
return typeof sender?.email === "string" && sender.email !== "" && sender.type === "HUMAN" ? sender.email : undefined;
|
|
@@ -267,6 +289,8 @@ function emailOf(sender: RawChatSender | undefined): string | undefined {
|
|
|
267
289
|
/** Parses one decoded Pub/Sub event, or `null` if it isn't a kind this provider acts on. */
|
|
268
290
|
export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardClickEvent | null {
|
|
269
291
|
const event = raw as RawChatEvent;
|
|
292
|
+
const locale = localeOf(event);
|
|
293
|
+
const timeZone = timeZoneOf(event);
|
|
270
294
|
if (event?.type === "MESSAGE") {
|
|
271
295
|
const m = event.message;
|
|
272
296
|
if (!m || typeof m.name !== "string" || typeof m.text !== "string") return null;
|
|
@@ -281,6 +305,8 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
|
|
|
281
305
|
senderDisplayName: m.sender.displayName,
|
|
282
306
|
senderEmail: emailOf(m.sender),
|
|
283
307
|
isDirectMessage: m.space.type === "DM",
|
|
308
|
+
...(locale === undefined ? {} : { locale }),
|
|
309
|
+
...(timeZone === undefined ? {} : { timeZone }),
|
|
284
310
|
};
|
|
285
311
|
}
|
|
286
312
|
if (event?.type === "CARD_CLICKED") {
|
|
@@ -300,6 +326,8 @@ export function parseChatEvent(raw: unknown): ParsedMessageEvent | ParsedCardCli
|
|
|
300
326
|
senderEmail: emailOf(event.user),
|
|
301
327
|
parameters,
|
|
302
328
|
isDirectMessage: event.space.type === "DM",
|
|
329
|
+
...(locale === undefined ? {} : { locale }),
|
|
330
|
+
...(timeZone === undefined ? {} : { timeZone }),
|
|
303
331
|
};
|
|
304
332
|
}
|
|
305
333
|
return null;
|
|
@@ -312,6 +340,10 @@ export type CardClickHandler = (
|
|
|
312
340
|
senderEmail?: string,
|
|
313
341
|
isDirectMessage?: boolean,
|
|
314
342
|
messageName?: string,
|
|
343
|
+
/** The clicking person's client language (`ParsedCardClickEvent.locale`). */
|
|
344
|
+
locale?: string,
|
|
345
|
+
/** The clicking person's client time zone (`ParsedCardClickEvent.timeZone`). */
|
|
346
|
+
timeZone?: string,
|
|
315
347
|
) => Promise<void>;
|
|
316
348
|
|
|
317
349
|
export type GoogleChatProviderDeps = {
|
|
@@ -319,19 +351,22 @@ export type GoogleChatProviderDeps = {
|
|
|
319
351
|
subscription: string;
|
|
320
352
|
/**
|
|
321
353
|
* Resolves a confirmation token against the core's shared store, injected by
|
|
322
|
-
* the channel loader (`ChannelRuntimeContext.confirm`). Returns
|
|
323
|
-
*
|
|
354
|
+
* the channel loader (`ChannelRuntimeContext.confirm`). Returns what to tell
|
|
355
|
+
* the person, in their language, or `null` when the input wasn't
|
|
356
|
+
* token-shaped. With
|
|
324
357
|
* `resolveConfirmation` below, the channel's whole contact with confirmation:
|
|
325
358
|
* the store, vault and note-writer stay in the core.
|
|
326
359
|
*/
|
|
327
|
-
confirm:
|
|
360
|
+
confirm: ChannelRuntimeContext["confirm"];
|
|
328
361
|
/**
|
|
329
362
|
* The structured sibling of `confirm` (`ChannelRuntimeContext.resolveConfirmation`).
|
|
330
363
|
* When it's there, a click resolves through it, and an accepted choice
|
|
331
364
|
* turns its card into the choice taken; without it, a click goes through
|
|
332
365
|
* `confirm` and the card stays as it was.
|
|
333
366
|
*/
|
|
334
|
-
resolveConfirmation?:
|
|
367
|
+
resolveConfirmation?: ChannelRuntimeContext["resolveConfirmation"];
|
|
368
|
+
/** The texts the cards show, in each person's language (`ChannelRuntimeContext.i18n`); English when absent. */
|
|
369
|
+
i18n?: ChannelI18n;
|
|
335
370
|
/**
|
|
336
371
|
* Handles a `CARD_CLICKED` event's action parameters. Defaults to resolving
|
|
337
372
|
* the confirm button's token through `deps.confirm` (the same path a bare
|
|
@@ -366,6 +401,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
366
401
|
const subscriptionFn = deps.subscriptionFn ?? openSubscription;
|
|
367
402
|
const clientDeps = { tokenSource };
|
|
368
403
|
const sentMessageNames = new Set<string>();
|
|
404
|
+
/** The texts for `principal`, on the client language `hint`. */
|
|
405
|
+
const textsFor = async (principal: Principal, hint: string | undefined): Promise<Translate> =>
|
|
406
|
+
deps.i18n ? (await deps.i18n.forRequest(principal, hint)).t : englishChat;
|
|
369
407
|
// Per-session serialization: pollOnce's setInterval fires on a fixed
|
|
370
408
|
// clock regardless of whether the previous tick's turn finished, so two
|
|
371
409
|
// overlapping ticks could otherwise both call handleTurn for the SAME
|
|
@@ -399,9 +437,17 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
399
437
|
}
|
|
400
438
|
}
|
|
401
439
|
|
|
402
|
-
/** Resumes the session's turn after a confirmation whose result went back to the model (`
|
|
403
|
-
function resumeTurn(
|
|
440
|
+
/** Resumes the session's turn after a confirmation whose result went back to the model (`ConfirmReply.resume`). */
|
|
441
|
+
async function resumeTurn(
|
|
442
|
+
handleTurn: HandleTurn,
|
|
443
|
+
space: string,
|
|
444
|
+
principal: Principal,
|
|
445
|
+
isDirectMessage: boolean,
|
|
446
|
+
locale: string | undefined,
|
|
447
|
+
timeZone: string | undefined,
|
|
448
|
+
): Promise<void> {
|
|
404
449
|
const sessionKey = deriveSessionKey(space, principal.id);
|
|
450
|
+
const t = await textsFor(principal, locale);
|
|
405
451
|
return runInSession(sessionKey, () =>
|
|
406
452
|
handleTurn(
|
|
407
453
|
{
|
|
@@ -412,8 +458,10 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
412
458
|
principal,
|
|
413
459
|
logPrefix: `[chat:${space}:${principal.id}] `,
|
|
414
460
|
resumeAfterConfirm: true,
|
|
461
|
+
...(locale === undefined ? {} : { languageHint: locale }),
|
|
462
|
+
...(timeZone === undefined ? {} : { timeZoneHint: timeZone }),
|
|
415
463
|
},
|
|
416
|
-
createSink(space, { user: principal.id, isDirectMessage }),
|
|
464
|
+
createSink(space, { user: principal.id, isDirectMessage }, t),
|
|
417
465
|
),
|
|
418
466
|
);
|
|
419
467
|
}
|
|
@@ -429,7 +477,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
429
477
|
*/
|
|
430
478
|
const onCardClick: CardClickHandler =
|
|
431
479
|
deps.onCardClick ??
|
|
432
|
-
(async (params, space, sender, senderEmail, isDirectMessage = false, messageName) => {
|
|
480
|
+
(async (params, space, sender, senderEmail, isDirectMessage = false, messageName, locale, timeZone) => {
|
|
433
481
|
const token = params.token;
|
|
434
482
|
if (!token) {
|
|
435
483
|
log(`[chat] card click with no token parameter`);
|
|
@@ -441,13 +489,14 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
441
489
|
...(senderEmail === undefined ? {} : { claims: { email: senderEmail } }),
|
|
442
490
|
};
|
|
443
491
|
const sessionKey = deriveSessionKey(space, sender);
|
|
444
|
-
let reply:
|
|
492
|
+
let reply: ConfirmReply | null;
|
|
445
493
|
if (deps.resolveConfirmation) {
|
|
446
|
-
const outcome = await deps.resolveConfirmation(token, sessionKey, principal);
|
|
494
|
+
const outcome = await deps.resolveConfirmation(token, sessionKey, principal, locale);
|
|
447
495
|
const accepted = outcome.status === "ok" || outcome.status === "failed" || outcome.status === "resume";
|
|
448
496
|
if (accepted && messageName !== undefined && params.summary !== undefined) {
|
|
449
497
|
try {
|
|
450
|
-
await
|
|
498
|
+
const t = await textsFor(principal, locale);
|
|
499
|
+
await updateCardFn(messageName, buildChosenCard(params.summary, params.label, t), clientDeps);
|
|
451
500
|
log(`[chat:${space}] [out] confirm card ${messageName} patched to the choice taken`);
|
|
452
501
|
} catch (err) {
|
|
453
502
|
log(`[chat:${space}] confirm card patch failed: ${String(err)}`);
|
|
@@ -456,17 +505,19 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
456
505
|
// On not-found nothing was taken, so `confirm` gives the reply as it
|
|
457
506
|
// always did: the core's own message for someone it won't talk to,
|
|
458
507
|
// which the outcome doesn't carry.
|
|
459
|
-
|
|
508
|
+
if (outcome.status === "not-found") reply = await deps.confirm(token, sessionKey, principal, locale);
|
|
509
|
+
else if (outcome.text === null) reply = null;
|
|
510
|
+
else reply = outcome.status === "resume" ? { text: outcome.text, resume: true } : { text: outcome.text };
|
|
460
511
|
} else {
|
|
461
|
-
reply = await deps.confirm(token, sessionKey, principal);
|
|
512
|
+
reply = await deps.confirm(token, sessionKey, principal, locale);
|
|
462
513
|
}
|
|
463
|
-
if (reply
|
|
464
|
-
await resumeTurn(activeHandleTurn, space, principal, isDirectMessage);
|
|
514
|
+
if (reply?.resume && activeHandleTurn) {
|
|
515
|
+
await resumeTurn(activeHandleTurn, space, principal, isDirectMessage, locale, timeZone);
|
|
465
516
|
return;
|
|
466
517
|
}
|
|
467
518
|
if (reply !== null) {
|
|
468
|
-
log(`[chat:${space}] [out] ${reply}`);
|
|
469
|
-
const sent = await sendMessageFn(space, reply, clientDeps);
|
|
519
|
+
log(`[chat:${space}] [out] ${reply.text}`);
|
|
520
|
+
const sent = await sendMessageFn(space, reply.text, clientDeps);
|
|
470
521
|
sentMessageNames.add(sent.name);
|
|
471
522
|
}
|
|
472
523
|
});
|
|
@@ -476,8 +527,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
476
527
|
* Per-turn output sink — same responsibilities as the retired `ChatStreamer`,
|
|
477
528
|
* rebuilt against the new client. `viewer` is the person the turn is for;
|
|
478
529
|
* outside a direct message, what only they may see is sent to them alone.
|
|
530
|
+
* `t` gives the cards' texts in their language.
|
|
479
531
|
*/
|
|
480
|
-
function createSink(space: string, viewer: { user: string; isDirectMessage: boolean }): TurnSink {
|
|
532
|
+
function createSink(space: string, viewer: { user: string; isDirectMessage: boolean }, t: Translate): TurnSink {
|
|
481
533
|
let chain: Promise<void> = Promise.resolve();
|
|
482
534
|
|
|
483
535
|
function enqueue(fn: () => Promise<void>): void {
|
|
@@ -519,7 +571,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
519
571
|
const shownLogins = new Set<string>();
|
|
520
572
|
enqueue(async () => {
|
|
521
573
|
log(`[chat:${space}] [out] receiving card`);
|
|
522
|
-
const sent = await sendCardFn(space, buildReceivingCard(), clientDeps);
|
|
574
|
+
const sent = await sendCardFn(space, buildReceivingCard(t), clientDeps);
|
|
523
575
|
sentMessageNames.add(sent.name);
|
|
524
576
|
receivingCardName = sent.name;
|
|
525
577
|
});
|
|
@@ -531,7 +583,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
531
583
|
return;
|
|
532
584
|
}
|
|
533
585
|
enqueue(async () => {
|
|
534
|
-
const card = buildToolCallCard(label, detail ?? "", "loading");
|
|
586
|
+
const card = buildToolCallCard(label, detail ?? "", "loading", t);
|
|
535
587
|
log(`[chat:${space}] [out] tool card: ${label}`);
|
|
536
588
|
const sent = await sendCardFn(space, card, clientDeps);
|
|
537
589
|
sentMessageNames.add(sent.name);
|
|
@@ -545,7 +597,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
545
597
|
log(`[chat:${space}] onToolFinish for unknown toolCallId ${toolCallId}`);
|
|
546
598
|
return;
|
|
547
599
|
}
|
|
548
|
-
const card = buildToolCallCard(entry.label, entry.detail, outcome);
|
|
600
|
+
const card = buildToolCallCard(entry.label, entry.detail, outcome, t);
|
|
549
601
|
log(`[chat:${space}] [out] patching ${entry.name} to "${outcome}"`);
|
|
550
602
|
const patched = await updateCardFn(entry.name, card, clientDeps);
|
|
551
603
|
log(`[chat:${space}] [out] patched ${patched.name}`);
|
|
@@ -562,9 +614,9 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
562
614
|
entry = { name: undefined, buffer: chunk };
|
|
563
615
|
reasoningCards.set(id, entry);
|
|
564
616
|
enqueue(async () => {
|
|
565
|
-
const card = buildToolCallCard("
|
|
617
|
+
const card = buildToolCallCard(t("google-chat.thinking"), "", "loading", t);
|
|
566
618
|
if (receivingCardName !== undefined) {
|
|
567
|
-
// Replace the "
|
|
619
|
+
// Replace the "Status" placeholder in place instead of
|
|
568
620
|
// sending a second message.
|
|
569
621
|
log(`[chat:${space}] [out] reasoning card (${id}) — replacing receiving card`);
|
|
570
622
|
const patched = await updateCardFn(receivingCardName, card, clientDeps);
|
|
@@ -591,7 +643,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
591
643
|
enqueue(async () => {
|
|
592
644
|
if (entry.name === undefined) return; // shouldn't happen given enqueue's own FIFO ordering, but stays defensive
|
|
593
645
|
const status = failed ? "failed" : "success";
|
|
594
|
-
const card = buildToolCallCard("
|
|
646
|
+
const card = buildToolCallCard(t("google-chat.thinking"), tailTruncate(entry.buffer, REASONING_TAIL_CHARS), status, t);
|
|
595
647
|
log(`[chat:${space}] [out] reasoning patch (${id}): status=${status} bufferLen=${entry.buffer.length}`);
|
|
596
648
|
await updateCardFn(entry.name, card, clientDeps);
|
|
597
649
|
reasoningCards.delete(id);
|
|
@@ -607,7 +659,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
607
659
|
if (pending) {
|
|
608
660
|
enqueue(async () => {
|
|
609
661
|
log(`[chat:${space}] [out] confirm card: ${pending.summary}`);
|
|
610
|
-
const sent = await sendCardFn(space, buildConfirmCard(pending), clientDeps);
|
|
662
|
+
const sent = await sendCardFn(space, buildConfirmCard(pending, t), clientDeps);
|
|
611
663
|
sentMessageNames.add(sent.name);
|
|
612
664
|
});
|
|
613
665
|
}
|
|
@@ -617,14 +669,18 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
617
669
|
enqueue(async () => {
|
|
618
670
|
log(`[chat:${space}] [out] login card: ${login.service}`);
|
|
619
671
|
const options = viewer.isDirectMessage ? undefined : { privateViewer: viewer.user };
|
|
620
|
-
const sent = await sendCardFn(space, buildLoginCard(login), clientDeps, options);
|
|
672
|
+
const sent = await sendCardFn(space, buildLoginCard(login, t), clientDeps, options);
|
|
621
673
|
sentMessageNames.add(sent.name);
|
|
622
674
|
});
|
|
623
675
|
}
|
|
624
676
|
},
|
|
625
|
-
finalize: async (finalText
|
|
626
|
-
|
|
627
|
-
|
|
677
|
+
finalize: async (finalText, info) => {
|
|
678
|
+
// The core's stand-in for an answer the model didn't write: the
|
|
679
|
+
// confirmation card already says it; only a list after it is sent.
|
|
680
|
+
const note = info?.pendingNote;
|
|
681
|
+
const shown = note !== undefined && finalText.startsWith(note) ? finalText.slice(note.length) : finalText;
|
|
682
|
+
const trimmed = shown.trim();
|
|
683
|
+
if (trimmed.length > 0 && trimmed !== NO_REPLY) {
|
|
628
684
|
for (const message of splitForSendLimit(trimmed)) {
|
|
629
685
|
sendPlain(message);
|
|
630
686
|
}
|
|
@@ -651,19 +707,20 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
651
707
|
...(event.senderEmail === undefined ? {} : { claims: { email: event.senderEmail } }),
|
|
652
708
|
};
|
|
653
709
|
|
|
654
|
-
const
|
|
655
|
-
if (
|
|
656
|
-
await resumeTurn(handleTurn, event.space, principal, event.isDirectMessage);
|
|
710
|
+
const reply = await deps.confirm(event.text, sessionKey, principal, event.locale);
|
|
711
|
+
if (reply?.resume) {
|
|
712
|
+
await resumeTurn(handleTurn, event.space, principal, event.isDirectMessage, event.locale, event.timeZone);
|
|
657
713
|
return;
|
|
658
714
|
}
|
|
659
|
-
if (
|
|
660
|
-
log(`[chat:${event.space}] [out] ${
|
|
661
|
-
const sent = await sendMessageFn(event.space,
|
|
715
|
+
if (reply !== null) {
|
|
716
|
+
log(`[chat:${event.space}] [out] ${reply.text}`);
|
|
717
|
+
const sent = await sendMessageFn(event.space, reply.text, clientDeps);
|
|
662
718
|
sentMessageNames.add(sent.name);
|
|
663
719
|
return;
|
|
664
720
|
}
|
|
665
721
|
|
|
666
|
-
|
|
722
|
+
const t = await textsFor(principal, event.locale);
|
|
723
|
+
// Only the actual model turn needs serializing — the confirmation above
|
|
667
724
|
// never touches SessionHistory, so it's always safe to run right
|
|
668
725
|
// away regardless of whether this session is mid-turn.
|
|
669
726
|
await runInSession(sessionKey, () =>
|
|
@@ -675,8 +732,10 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
675
732
|
sessionKey,
|
|
676
733
|
principal,
|
|
677
734
|
logPrefix: `[chat:${event.space}:${event.sender}] `,
|
|
735
|
+
...(event.locale === undefined ? {} : { languageHint: event.locale }),
|
|
736
|
+
...(event.timeZone === undefined ? {} : { timeZoneHint: event.timeZone }),
|
|
678
737
|
},
|
|
679
|
-
createSink(event.space, { user: event.sender, isDirectMessage: event.isDirectMessage }),
|
|
738
|
+
createSink(event.space, { user: event.sender, isDirectMessage: event.isDirectMessage }, t),
|
|
680
739
|
),
|
|
681
740
|
);
|
|
682
741
|
}
|
|
@@ -709,7 +768,7 @@ export function createGoogleChatProvider(deps: GoogleChatProviderDeps): GoogleCh
|
|
|
709
768
|
if (parsed.kind === "message") {
|
|
710
769
|
await processMessageEvent(parsed, handleTurn);
|
|
711
770
|
} else {
|
|
712
|
-
await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail, parsed.isDirectMessage, parsed.messageName);
|
|
771
|
+
await onCardClick(parsed.parameters, parsed.space, parsed.sender, parsed.senderEmail, parsed.isDirectMessage, parsed.messageName, parsed.locale, parsed.timeZone);
|
|
713
772
|
}
|
|
714
773
|
} catch (err) {
|
|
715
774
|
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:
|
|
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.
|
|
3
|
+
"version": "0.10.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.54.0 <1.0.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@mercury-fw/channel-types": "0.
|
|
37
|
+
"@mercury-fw/channel-types": "0.56.0",
|
|
38
38
|
"@mercury-fw/typescript-config": "*",
|
|
39
39
|
"@types/bun": "^1.4.2",
|
|
40
40
|
"typescript": "^6.0.3"
|