@open-everywhere/provider-google-chat 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +51 -0
- package/README.md +40 -2
- package/agent/channels/google-chat-auth.ts +145 -0
- package/agent/channels/google-chat-front.ts +337 -0
- package/agent/channels/google-chat-identity.ts +97 -0
- package/agent/channels/google-chat-message-ops.ts +30 -0
- package/agent/channels/google-chat-origin-codec.ts +32 -0
- package/agent/channels/google-chat-post-arm.ts +36 -0
- package/agent/channels/google-chat-rest.ts +87 -0
- package/agent/channels/google-chat.ts +193 -0
- package/agent/contributions.ts +16 -0
- package/agent/docs-generated.ts +15 -0
- package/agent/policy.ts +101 -0
- package/agent/provider-events.ts +18 -0
- package/agent/register.ts +48 -0
- package/agent/surface-parity.ts +63 -0
- package/dist/agent/channels/google-chat-auth.d.ts +39 -0
- package/dist/agent/channels/google-chat-auth.d.ts.map +1 -0
- package/dist/agent/channels/google-chat-auth.js +118 -0
- package/dist/agent/channels/google-chat-auth.js.map +1 -0
- package/dist/agent/channels/google-chat-front.d.ts +101 -0
- package/dist/agent/channels/google-chat-front.d.ts.map +1 -0
- package/dist/agent/channels/google-chat-front.js +258 -0
- package/dist/agent/channels/google-chat-front.js.map +1 -0
- package/dist/agent/channels/google-chat-identity.d.ts +28 -0
- package/dist/agent/channels/google-chat-identity.d.ts.map +1 -0
- package/dist/agent/channels/google-chat-identity.js +70 -0
- package/dist/agent/channels/google-chat-identity.js.map +1 -0
- package/dist/agent/channels/google-chat-message-ops.d.ts +10 -0
- package/dist/agent/channels/google-chat-message-ops.d.ts.map +1 -0
- package/dist/agent/channels/google-chat-message-ops.js +25 -0
- package/dist/agent/channels/google-chat-message-ops.js.map +1 -0
- package/dist/agent/channels/google-chat-origin-codec.d.ts +6 -0
- package/dist/agent/channels/google-chat-origin-codec.d.ts.map +1 -0
- package/dist/agent/channels/google-chat-origin-codec.js +31 -0
- package/dist/agent/channels/google-chat-origin-codec.js.map +1 -0
- package/dist/agent/channels/google-chat-post-arm.d.ts +11 -0
- package/dist/agent/channels/google-chat-post-arm.d.ts.map +1 -0
- package/dist/agent/channels/google-chat-post-arm.js +31 -0
- package/dist/agent/channels/google-chat-post-arm.js.map +1 -0
- package/dist/agent/channels/google-chat-rest.d.ts +21 -0
- package/dist/agent/channels/google-chat-rest.d.ts.map +1 -0
- package/dist/agent/channels/google-chat-rest.js +81 -0
- package/dist/agent/channels/google-chat-rest.js.map +1 -0
- package/dist/agent/channels/google-chat.d.ts +3 -0
- package/dist/agent/channels/google-chat.d.ts.map +1 -0
- package/dist/agent/channels/google-chat.js +156 -0
- package/dist/agent/channels/google-chat.js.map +1 -0
- package/dist/agent/contributions.d.ts +12 -0
- package/dist/agent/contributions.d.ts.map +1 -0
- package/dist/agent/contributions.js +16 -0
- package/dist/agent/contributions.js.map +1 -0
- package/dist/agent/docs-generated.d.ts +3 -0
- package/dist/agent/docs-generated.d.ts.map +1 -0
- package/dist/agent/docs-generated.js +13 -0
- package/dist/agent/docs-generated.js.map +1 -0
- package/dist/agent/policy.d.ts +38 -0
- package/dist/agent/policy.d.ts.map +1 -0
- package/dist/agent/policy.js +78 -0
- package/dist/agent/policy.js.map +1 -0
- package/dist/agent/provider-events.d.ts +3 -0
- package/dist/agent/provider-events.d.ts.map +1 -0
- package/dist/agent/provider-events.js +16 -0
- package/dist/agent/provider-events.js.map +1 -0
- package/dist/agent/register.d.ts +3 -0
- package/dist/agent/register.d.ts.map +1 -0
- package/dist/agent/register.js +37 -0
- package/dist/agent/register.js.map +1 -0
- package/dist/agent/surface-parity.d.ts +3 -0
- package/dist/agent/surface-parity.d.ts.map +1 -0
- package/dist/agent/surface-parity.js +59 -0
- package/dist/agent/surface-parity.js.map +1 -0
- package/package.json +116 -4
- package/tsconfig.build.json +22 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// google-chat-front — the eve-free core of the Google Chat surface's inbound path. One delivery in, one outcome
|
|
2
|
+
// out: not configured, unauthorized, acknowledged and ignored, a duplicate, refused (audited, no model turn), or
|
|
3
|
+
// admitted and delivered into a seat. The live wiring (google-chat.ts) supplies the store, the config and the two
|
|
4
|
+
// seat dispatches.
|
|
5
|
+
//
|
|
6
|
+
// THE ORDER IS LOAD-BEARING:
|
|
7
|
+
// 1. policy — the surface SHIPS OFF. A disabled policy, or one with no audience, answers "not configured"
|
|
8
|
+
// before the body is read: an external ingress nobody configured is not reachable.
|
|
9
|
+
// 2. verify — the bearer token is checked against Google's keys and the policy's audience (fail closed).
|
|
10
|
+
// Nothing before it reads the body, and an unverified delivery writes no audit row.
|
|
11
|
+
// 3. parse — anything that is not a well-formed MESSAGE event from a HUMAN sender is acknowledged and
|
|
12
|
+
// dropped (an ADDED_TO_SPACE, a bot's own message, a CARD_CLICKED this surface does not use).
|
|
13
|
+
// 4. dedupe — the message's resource name is claimed once, so a retried event never wakes twice.
|
|
14
|
+
// 5. route — the person's own DM with the app wakes their DM seat (internal people only, and only while
|
|
15
|
+
// the policy allows direct messages); a group chat is refused; a space wakes the standing seat
|
|
16
|
+
// that owns `google-chat/<workspace>/<space id>`, only when the message @-mentions the app or
|
|
17
|
+
// is a slash command. With no standing-seat window for the space it is refused `no-domain`.
|
|
18
|
+
// 6. wake — the message's own text (the mention removed) renders as the graded trigger. An outside sender
|
|
19
|
+
// stamps the non-member carriage, exactly as a guest's message does on every chat surface.
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
mkMessageTrigger,
|
|
23
|
+
readCappedBody,
|
|
24
|
+
renderWake,
|
|
25
|
+
stampNonMemberChatAuth,
|
|
26
|
+
type IngestedChatMessage,
|
|
27
|
+
type NormalizedWake,
|
|
28
|
+
type UnifiedWakeOrigin,
|
|
29
|
+
type WakeMessageInput,
|
|
30
|
+
type WakeTrust,
|
|
31
|
+
} from "@open-everywhere/core/agent/adapter-api.js";
|
|
32
|
+
import { googleChatSurfaceKey, type GoogleChatPolicy } from "../policy.js";
|
|
33
|
+
import { verifyGoogleChatToken, type GoogleChatVerifyDeps } from "./google-chat-auth.js";
|
|
34
|
+
import { buildGoogleChatAuth, type OpenEverywhereGoogleChatAuth } from "./google-chat-identity.js";
|
|
35
|
+
import { isMessageNameIn, isSpaceName, isThreadNameIn } from "./google-chat-rest.js";
|
|
36
|
+
|
|
37
|
+
type GoogleChatOrigin = Extract<UnifiedWakeOrigin, { platform: "google-chat" }>;
|
|
38
|
+
|
|
39
|
+
/** The largest message text that reaches the model; the rest is cut and the cut is said. */
|
|
40
|
+
const GOOGLE_CHAT_MAX_INBOUND_CHARS = 20_000;
|
|
41
|
+
|
|
42
|
+
/** Why the front refused a verified message. */
|
|
43
|
+
export type GoogleChatRefusal = "group-chat" | "dm-off" | "external-dm" | "no-domain";
|
|
44
|
+
|
|
45
|
+
export type GoogleChatFrontOutcome =
|
|
46
|
+
| { readonly kind: "not-configured" }
|
|
47
|
+
| { readonly kind: "unauthorized"; readonly reason: string }
|
|
48
|
+
| { readonly kind: "ignored"; readonly why: string }
|
|
49
|
+
| { readonly kind: "duplicate" }
|
|
50
|
+
| { readonly kind: "refused"; readonly reason: GoogleChatRefusal }
|
|
51
|
+
| { readonly kind: "admitted"; readonly route: "room" | "dm"; readonly surfaceKey: string };
|
|
52
|
+
|
|
53
|
+
/** The parsed facts of one MESSAGE event — only what the gate and the wake read. */
|
|
54
|
+
export interface GoogleChatMessageEvent {
|
|
55
|
+
readonly space: string;
|
|
56
|
+
readonly spaceKind: "space" | "dm" | "group";
|
|
57
|
+
readonly messageName: string;
|
|
58
|
+
readonly thread?: string;
|
|
59
|
+
readonly threadReply: boolean;
|
|
60
|
+
readonly sender: string;
|
|
61
|
+
readonly senderName?: string;
|
|
62
|
+
readonly domainId?: string;
|
|
63
|
+
/** The text with the app's mention removed (`argumentText`), else the whole text. */
|
|
64
|
+
readonly text: string;
|
|
65
|
+
/** Did the message @-mention the app, or invoke one of its slash commands? */
|
|
66
|
+
readonly addressed: boolean;
|
|
67
|
+
readonly createTime: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Everything one admitted message's dispatch needs. */
|
|
71
|
+
export interface GoogleChatWakePlan {
|
|
72
|
+
readonly surfaceKey: string;
|
|
73
|
+
readonly route: "room" | "dm";
|
|
74
|
+
readonly auth: OpenEverywhereGoogleChatAuth;
|
|
75
|
+
readonly origin: GoogleChatOrigin;
|
|
76
|
+
readonly trigger: WakeMessageInput;
|
|
77
|
+
readonly trust: "internal" | "external";
|
|
78
|
+
readonly ingested: readonly IngestedChatMessage[];
|
|
79
|
+
readonly event: GoogleChatMessageEvent;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface GoogleChatFrontDeps extends GoogleChatVerifyDeps {
|
|
83
|
+
readonly policy: () => GoogleChatPolicy;
|
|
84
|
+
/** Durable first-sighting claim (true = first time). */
|
|
85
|
+
readonly markSeen: (key: string) => Promise<boolean>;
|
|
86
|
+
/** Does a windowed domain own this space's surface key? (supervisorGrainFor + grainUsesUnifiedWindow.) */
|
|
87
|
+
readonly roomHasSeat: (surfaceKey: string) => boolean;
|
|
88
|
+
/** The per-message trust tier, resolved through the shared resolver over this auth. */
|
|
89
|
+
readonly wakeTrust: (auth: OpenEverywhereGoogleChatAuth, sender: string) => WakeTrust;
|
|
90
|
+
readonly dispatchRoom: (plan: GoogleChatWakePlan) => Promise<void>;
|
|
91
|
+
readonly dispatchDm: (plan: GoogleChatWakePlan) => Promise<void>;
|
|
92
|
+
readonly audit: (row: { readonly action: string; readonly workspace: string; readonly surface?: string; readonly principalId?: string; readonly detail: Record<string, unknown> }) => Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rec(v: unknown): Record<string, unknown> | undefined {
|
|
96
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function str(v: unknown): string | undefined {
|
|
100
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse one event body. Undefined (with a reason) for anything this surface does not take: not JSON, not a
|
|
105
|
+
* MESSAGE, a malformed name, or a sender that is not a human. Pure.
|
|
106
|
+
*/
|
|
107
|
+
export function parseGoogleChatEvent(rawBody: string): { readonly event: GoogleChatMessageEvent } | { readonly ignored: string } {
|
|
108
|
+
let j: unknown;
|
|
109
|
+
try {
|
|
110
|
+
j = JSON.parse(rawBody);
|
|
111
|
+
} catch {
|
|
112
|
+
return { ignored: "not-json" };
|
|
113
|
+
}
|
|
114
|
+
const e = rec(j);
|
|
115
|
+
if (!e || e["type"] !== "MESSAGE") return { ignored: "not-a-message" };
|
|
116
|
+
const space = rec(e["space"]);
|
|
117
|
+
const message = rec(e["message"]);
|
|
118
|
+
const sender = rec(message?.["sender"]);
|
|
119
|
+
const spaceName = space?.["name"];
|
|
120
|
+
if (!space || !isSpaceName(spaceName) || !message) return { ignored: "malformed" };
|
|
121
|
+
const messageName = message["name"];
|
|
122
|
+
if (!isMessageNameIn(messageName, spaceName)) return { ignored: "malformed" };
|
|
123
|
+
const threadName = rec(message["thread"])?.["name"];
|
|
124
|
+
if (threadName !== undefined && !isThreadNameIn(threadName, spaceName)) return { ignored: "malformed" };
|
|
125
|
+
// A bot's message (including this app's own) never wakes a seat: that is the loop guard.
|
|
126
|
+
if (sender?.["type"] !== "HUMAN") return { ignored: "not-a-human" };
|
|
127
|
+
const senderName = str(sender["name"]);
|
|
128
|
+
if (senderName === undefined || !/^users\/[A-Za-z0-9_-]{1,128}$/u.test(senderName)) return { ignored: "malformed" };
|
|
129
|
+
const spaceType = str(space["spaceType"]) ?? (space["type"] === "DM" ? "DIRECT_MESSAGE" : "SPACE");
|
|
130
|
+
const spaceKind: GoogleChatMessageEvent["spaceKind"] =
|
|
131
|
+
spaceType === "DIRECT_MESSAGE" ? (space["singleUserBotDm"] === true ? "dm" : "group") : spaceType === "GROUP_CHAT" ? "group" : "space";
|
|
132
|
+
const annotations = Array.isArray(message["annotations"]) ? message["annotations"] : [];
|
|
133
|
+
const mentionsApp = annotations.some((a) => {
|
|
134
|
+
const an = rec(a);
|
|
135
|
+
return an?.["type"] === "USER_MENTION" && rec(rec(an["userMention"])?.["user"])?.["type"] === "BOT";
|
|
136
|
+
});
|
|
137
|
+
const slash = rec(message["slashCommand"]) !== undefined;
|
|
138
|
+
const text = (str(message["argumentText"]) ?? str(message["text"]) ?? "").trim();
|
|
139
|
+
const domainId = str(sender["domainId"]);
|
|
140
|
+
const displayName = str(sender["displayName"]);
|
|
141
|
+
return {
|
|
142
|
+
event: {
|
|
143
|
+
space: spaceName,
|
|
144
|
+
spaceKind,
|
|
145
|
+
messageName,
|
|
146
|
+
...(threadName !== undefined ? { thread: threadName } : {}),
|
|
147
|
+
threadReply: message["threadReply"] === true && threadName !== undefined,
|
|
148
|
+
sender: senderName,
|
|
149
|
+
...(displayName !== undefined ? { senderName: displayName } : {}),
|
|
150
|
+
...(domainId !== undefined ? { domainId } : {}),
|
|
151
|
+
text,
|
|
152
|
+
addressed: mentionsApp || slash,
|
|
153
|
+
createTime: str(message["createTime"]) ?? str(e["eventTime"]) ?? new Date(0).toISOString(),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The sender's trust: internal only for a domain id the policy lists. */
|
|
159
|
+
function googleChatSenderTrust(policy: GoogleChatPolicy, domainId: string | undefined): "internal" | "external" {
|
|
160
|
+
return domainId !== undefined && policy.internalDomainIds.includes(domainId) ? "internal" : "external";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Build the wake plan for a message the gate admitted. Pure apart from the trust resolver dep. */
|
|
164
|
+
function planGoogleChatWake(
|
|
165
|
+
input: { readonly event: GoogleChatMessageEvent; readonly policy: GoogleChatPolicy; readonly route: "room" | "dm" },
|
|
166
|
+
wakeTrust: GoogleChatFrontDeps["wakeTrust"],
|
|
167
|
+
): GoogleChatWakePlan {
|
|
168
|
+
const { event, policy, route } = input;
|
|
169
|
+
const trustTier = googleChatSenderTrust(policy, event.domainId);
|
|
170
|
+
const surfaceKey = googleChatSurfaceKey(policy, event.space);
|
|
171
|
+
const base = buildGoogleChatAuth({
|
|
172
|
+
workspace: policy.workspace,
|
|
173
|
+
space: event.space,
|
|
174
|
+
mode: route === "dm" ? "dm" : "channel",
|
|
175
|
+
trust: trustTier,
|
|
176
|
+
sender: event.sender,
|
|
177
|
+
...(event.thread !== undefined ? { thread: event.thread } : {}),
|
|
178
|
+
messageName: event.messageName,
|
|
179
|
+
});
|
|
180
|
+
const trust = wakeTrust(base, event.sender);
|
|
181
|
+
const ingested: IngestedChatMessage[] = [{ trust, refuted: trustTier === "external" }];
|
|
182
|
+
const auth = stampNonMemberChatAuth(base as OpenEverywhereGoogleChatAuth & { attributes: Record<string, unknown> }, ingested, route === "dm" ? { dm: true } : { surfaceKey }) as OpenEverywhereGoogleChatAuth;
|
|
183
|
+
const body =
|
|
184
|
+
event.text === ""
|
|
185
|
+
? "(the message had no text)"
|
|
186
|
+
: event.text.length > GOOGLE_CHAT_MAX_INBOUND_CHARS
|
|
187
|
+
? `${event.text.slice(0, GOOGLE_CHAT_MAX_INBOUND_CHARS)}\n[… the message was cut at ${GOOGLE_CHAT_MAX_INBOUND_CHARS} characters]`
|
|
188
|
+
: event.text;
|
|
189
|
+
return {
|
|
190
|
+
surfaceKey,
|
|
191
|
+
route,
|
|
192
|
+
auth,
|
|
193
|
+
origin: {
|
|
194
|
+
platform: "google-chat",
|
|
195
|
+
workspace: policy.workspace,
|
|
196
|
+
space: event.space,
|
|
197
|
+
...(event.thread !== undefined ? { thread: event.thread } : {}),
|
|
198
|
+
messageName: event.messageName,
|
|
199
|
+
...(event.threadReply ? { threadReply: true as const } : {}),
|
|
200
|
+
},
|
|
201
|
+
trigger: {
|
|
202
|
+
from: "human",
|
|
203
|
+
author: event.senderName ?? event.sender,
|
|
204
|
+
authorId: event.sender,
|
|
205
|
+
id: event.messageName,
|
|
206
|
+
sentAt: event.createTime,
|
|
207
|
+
trust,
|
|
208
|
+
mention: true,
|
|
209
|
+
body,
|
|
210
|
+
},
|
|
211
|
+
trust: trustTier,
|
|
212
|
+
ingested,
|
|
213
|
+
event,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The NormalizedWake for an admitted message. */
|
|
218
|
+
function buildGoogleChatWake(plan: GoogleChatWakePlan, wakeId: string, tail?: readonly WakeMessageInput[]): NormalizedWake {
|
|
219
|
+
return {
|
|
220
|
+
platform: "google-chat",
|
|
221
|
+
reason: plan.route === "dm" ? "dm" : "mention",
|
|
222
|
+
addressing: "direct-mention",
|
|
223
|
+
containerNoun: "space",
|
|
224
|
+
wakeId,
|
|
225
|
+
locus: { channel: { id: plan.origin.space }, ...(plan.origin.thread !== undefined ? { thread: { ts: plan.origin.thread } } : {}) },
|
|
226
|
+
backscroll: tail && tail.length > 0 ? tail : undefined,
|
|
227
|
+
trigger: mkMessageTrigger(plan.trigger),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Render a plan's wake. */
|
|
232
|
+
export function renderGoogleChatWake(plan: GoogleChatWakePlan, wakeId: string, tail?: readonly WakeMessageInput[]): { message: string; context: readonly string[] } {
|
|
233
|
+
const r = renderWake(buildGoogleChatWake(plan, wakeId, tail));
|
|
234
|
+
return { message: r.message, context: r.context ?? [] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Steps 1 and 2: is the surface configured, and is this delivery Google Chat's? */
|
|
238
|
+
async function verifyGoogleChatDelivery(
|
|
239
|
+
authorization: string | undefined,
|
|
240
|
+
deps: Pick<GoogleChatFrontDeps, "policy" | "chatSystemKeys" | "googleOidcKeys">,
|
|
241
|
+
): Promise<{ readonly ok: true; readonly policy: GoogleChatPolicy } | { readonly ok: false; readonly outcome: GoogleChatFrontOutcome }> {
|
|
242
|
+
const policy = deps.policy();
|
|
243
|
+
if (!policy.enabled || policy.audience === null) return { ok: false, outcome: { kind: "not-configured" } };
|
|
244
|
+
const verdict = await verifyGoogleChatToken(authorization, policy.audience, deps);
|
|
245
|
+
if (!verdict.ok) return { ok: false, outcome: { kind: "unauthorized", reason: verdict.reason } };
|
|
246
|
+
return { ok: true, policy };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Steps 3 to 6 for a delivery already VERIFIED. Never throws for a bad event; a dispatch failure propagates. */
|
|
250
|
+
async function processVerifiedGoogleChatEvent(policy: GoogleChatPolicy, rawBody: string, deps: GoogleChatFrontDeps): Promise<GoogleChatFrontOutcome> {
|
|
251
|
+
const parsed = parseGoogleChatEvent(rawBody);
|
|
252
|
+
if ("ignored" in parsed) return { kind: "ignored", why: parsed.ignored };
|
|
253
|
+
const event = parsed.event;
|
|
254
|
+
if (event.spaceKind === "space" && !event.addressed) return { kind: "ignored", why: "not-addressed" };
|
|
255
|
+
if (!(await deps.markSeen(`google-chat:${event.messageName}`))) return { kind: "duplicate" };
|
|
256
|
+
|
|
257
|
+
const surfaceKey = googleChatSurfaceKey(policy, event.space);
|
|
258
|
+
const refuse = async (reason: GoogleChatRefusal): Promise<GoogleChatFrontOutcome> => {
|
|
259
|
+
await deps.audit({
|
|
260
|
+
action: "google-chat.refused",
|
|
261
|
+
workspace: policy.workspace,
|
|
262
|
+
surface: surfaceKey,
|
|
263
|
+
principalId: event.sender,
|
|
264
|
+
detail: { reason, space: event.space, domainId: event.domainId ?? null },
|
|
265
|
+
});
|
|
266
|
+
return { kind: "refused", reason };
|
|
267
|
+
};
|
|
268
|
+
if (event.spaceKind === "group") return refuse("group-chat");
|
|
269
|
+
const route: "room" | "dm" = event.spaceKind === "dm" ? "dm" : "room";
|
|
270
|
+
if (route === "dm") {
|
|
271
|
+
if (!policy.directMessages) return refuse("dm-off");
|
|
272
|
+
// A DM seat reads its person's private layer; an outside person never gets one.
|
|
273
|
+
if (googleChatSenderTrust(policy, event.domainId) !== "internal") return refuse("external-dm");
|
|
274
|
+
} else if (!deps.roomHasSeat(surfaceKey)) {
|
|
275
|
+
return refuse("no-domain");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const plan = planGoogleChatWake({ event, policy, route }, deps.wakeTrust);
|
|
279
|
+
if (route === "room") await deps.dispatchRoom(plan);
|
|
280
|
+
else await deps.dispatchDm(plan);
|
|
281
|
+
await deps.audit({
|
|
282
|
+
action: "google-chat.admitted",
|
|
283
|
+
workspace: policy.workspace,
|
|
284
|
+
surface: plan.surfaceKey,
|
|
285
|
+
principalId: event.sender,
|
|
286
|
+
detail: { space: event.space, route, trust: plan.trust, domainId: event.domainId ?? null, threadReply: event.threadReply },
|
|
287
|
+
});
|
|
288
|
+
return { kind: "admitted", route, surfaceKey: plan.surfaceKey };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The whole path in one call (the tests' entry): verify, then process. */
|
|
292
|
+
export async function handleGoogleChatDelivery(authorization: string | undefined, rawBody: string, deps: GoogleChatFrontDeps): Promise<GoogleChatFrontOutcome> {
|
|
293
|
+
const v = await verifyGoogleChatDelivery(authorization, deps);
|
|
294
|
+
if (!v.ok) return v.outcome;
|
|
295
|
+
return processVerifiedGoogleChatEvent(v.policy, rawBody, deps);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The largest event body the route reads. A Chat message is at most 32 KB; the event wraps it. */
|
|
299
|
+
export const GOOGLE_CHAT_INGRESS_MAX_BODY_BYTES = 512 * 1024;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The whole HTTP route, eve-free so a test drives it. 🔴 THE ORDER IS THE DEFENCE: the policy (503) and the token
|
|
303
|
+
* (401) are checked BEFORE the body is read, so an unconfigured or unauthenticated caller cannot make the route
|
|
304
|
+
* read a byte. Then the body cap (413), then `{}` with the rest in the background (`waitUntil`).
|
|
305
|
+
*/
|
|
306
|
+
export async function handleGoogleChatRequest(
|
|
307
|
+
req: Request,
|
|
308
|
+
waitUntil: (p: Promise<unknown>) => void,
|
|
309
|
+
deps: GoogleChatFrontDeps,
|
|
310
|
+
hooks: { readonly log?: (msg: string) => void; readonly onFailure?: (err: unknown, policy: GoogleChatPolicy) => Promise<void> } = {},
|
|
311
|
+
): Promise<Response> {
|
|
312
|
+
const log = hooks.log ?? (() => undefined);
|
|
313
|
+
const verified = await verifyGoogleChatDelivery(req.headers.get("authorization") ?? undefined, deps);
|
|
314
|
+
if (!verified.ok) {
|
|
315
|
+
if (verified.outcome.kind === "unauthorized") {
|
|
316
|
+
log(`delivery refused (401): ${verified.outcome.reason}`);
|
|
317
|
+
return new Response("unauthorized", { status: 401 });
|
|
318
|
+
}
|
|
319
|
+
return new Response("google chat surface not configured", { status: 503 });
|
|
320
|
+
}
|
|
321
|
+
const read = await readCappedBody(req, GOOGLE_CHAT_INGRESS_MAX_BODY_BYTES);
|
|
322
|
+
if ("tooLarge" in read) return new Response(`payload exceeds ${GOOGLE_CHAT_INGRESS_MAX_BODY_BYTES} bytes`, { status: 413 });
|
|
323
|
+
waitUntil(
|
|
324
|
+
processVerifiedGoogleChatEvent(verified.policy, read.body, deps)
|
|
325
|
+
.then((outcome) =>
|
|
326
|
+
log(
|
|
327
|
+
`event ${outcome.kind}${outcome.kind === "refused" ? ` (${outcome.reason})` : outcome.kind === "admitted" ? ` → ${outcome.route} ${outcome.surfaceKey}` : outcome.kind === "ignored" ? ` (${outcome.why})` : ""}`,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
.catch(async (err: unknown) => {
|
|
331
|
+
log(`event FAILED after the ack: ${err instanceof Error ? err.name : "error"}`);
|
|
332
|
+
await hooks.onFailure?.(err, verified.policy);
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
// `{}` = no synchronous message. The seat answers through the post arm.
|
|
336
|
+
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
|
337
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Pure Google Chat → Open Everywhere identity capture. The front (google-chat-front.ts) stows the HOST-DECIDED
|
|
2
|
+
// facts of one admitted event into the wake's auth `attributes`, and the mapper below interprets them into the
|
|
3
|
+
// binding. The security-load-bearing reading lives here, in one tested place.
|
|
4
|
+
//
|
|
5
|
+
// THE GRAIN:
|
|
6
|
+
// • workspace = the policy's `workspace` segment — one Google Workspace per deployment;
|
|
7
|
+
// • surface = the space id (`spaces/<id>` → `<id>`), so a domain's `reach` lists `google-chat/<ws>/<id>`;
|
|
8
|
+
// • mode = `dm` for the person's own direct message with the app, else `channel`;
|
|
9
|
+
// • visibility = ALWAYS `private`. A Chat space may be discoverable inside the org, but the event does not
|
|
10
|
+
// prove it, so nothing said in a space flows up to a shared tier by visibility alone;
|
|
11
|
+
// • trust = `internal` ONLY when the sender is a human whose Workspace domain id is one of the policy's
|
|
12
|
+
// `internalDomainIds`. Everyone else is `external`;
|
|
13
|
+
// • principal = the sender's Chat user resource name (`users/<id>`), never a display name or an email.
|
|
14
|
+
//
|
|
15
|
+
// §AUTH-ATTRIBUTE PROVENANCE: every attribute below is written by the front from the token-verified event and
|
|
16
|
+
// the policy, never from the message text and never by a model.
|
|
17
|
+
|
|
18
|
+
import type { BindableContext, IdentityMapper, RawIdentity } from "@open-everywhere/core/agent/adapter-api.js";
|
|
19
|
+
|
|
20
|
+
/** The structural match for eve's SessionAuthContext. */
|
|
21
|
+
export interface OpenEverywhereGoogleChatAuth {
|
|
22
|
+
readonly authenticator: string;
|
|
23
|
+
readonly principalId: string;
|
|
24
|
+
readonly principalType: string;
|
|
25
|
+
readonly attributes: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GoogleChatIdentityInput {
|
|
29
|
+
readonly workspace: string;
|
|
30
|
+
/** The space resource name (`spaces/<id>`). */
|
|
31
|
+
readonly space: string;
|
|
32
|
+
readonly mode: "channel" | "dm";
|
|
33
|
+
readonly trust: "internal" | "external";
|
|
34
|
+
/** The sender's user resource name (`users/<id>`). */
|
|
35
|
+
readonly sender: string;
|
|
36
|
+
readonly thread?: string;
|
|
37
|
+
readonly messageName: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Build the auth context the Google Chat surface dispatches with. */
|
|
41
|
+
export function buildGoogleChatAuth(input: GoogleChatIdentityInput): OpenEverywhereGoogleChatAuth {
|
|
42
|
+
return {
|
|
43
|
+
authenticator: "google-chat",
|
|
44
|
+
principalId: input.sender,
|
|
45
|
+
principalType: "google-chat-user",
|
|
46
|
+
attributes: {
|
|
47
|
+
gchat_workspace: input.workspace,
|
|
48
|
+
gchat_space: input.space,
|
|
49
|
+
gchat_surface: input.space.slice(input.space.indexOf("/") + 1),
|
|
50
|
+
gchat_mode: input.mode,
|
|
51
|
+
gchat_trust: input.trust,
|
|
52
|
+
gchat_message: input.messageName,
|
|
53
|
+
...(input.thread !== undefined ? { gchat_thread: input.thread } : {}),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type AuthAttrs = Readonly<Record<string, string | readonly string[]>> | undefined;
|
|
59
|
+
|
|
60
|
+
function authOf(ctx: BindableContext): { attributes?: AuthAttrs; principalId?: string } | null {
|
|
61
|
+
return ctx.session?.auth?.current ?? ctx.session?.auth?.initiator ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function attr(a: AuthAttrs, key: string): string | undefined {
|
|
65
|
+
const v = a?.[key];
|
|
66
|
+
const first = Array.isArray(v) ? v[0] : v;
|
|
67
|
+
return typeof first === "string" && first.length > 0 ? first : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The Google Chat identity mapper. FAIL CLOSED on every field it cannot read: no mode reads `channel`, no trust
|
|
72
|
+
* attribute reads `external`, and visibility is always `private`. `trustVerified` is set for a DM only when the
|
|
73
|
+
* front verified the sender internal, so a DM never lights the internal `dm` layer for an outsider.
|
|
74
|
+
*/
|
|
75
|
+
export const bindGoogleChat: IdentityMapper = (ctx): RawIdentity => {
|
|
76
|
+
const auth = authOf(ctx);
|
|
77
|
+
const a = auth?.attributes;
|
|
78
|
+
const mode = attr(a, "gchat_mode") === "dm" ? "dm" : "channel";
|
|
79
|
+
const internal = attr(a, "gchat_trust") === "internal";
|
|
80
|
+
const space = attr(a, "gchat_space");
|
|
81
|
+
const thread = attr(a, "gchat_thread");
|
|
82
|
+
const messageName = attr(a, "gchat_message");
|
|
83
|
+
const principalId = typeof auth?.principalId === "string" && auth.principalId.length > 0 ? auth.principalId : undefined;
|
|
84
|
+
return {
|
|
85
|
+
workspace: attr(a, "gchat_workspace"),
|
|
86
|
+
surface: attr(a, "gchat_surface"),
|
|
87
|
+
mode,
|
|
88
|
+
visibility: "private",
|
|
89
|
+
...(internal ? { trust: "internal" as const, ...(mode === "dm" ? { trustVerified: true } : {}) } : { trust: "external" as const }),
|
|
90
|
+
...(principalId ? { principalId } : {}),
|
|
91
|
+
// `channelId` is the space: the one address every shared room-notice path (an approval outcome note, a
|
|
92
|
+
// judge-lift notice) posts to through the surface message ops.
|
|
93
|
+
...(space
|
|
94
|
+
? { target: { space, channelId: space, ...(thread ? { thread } : {}), ...(messageName ? { messageName } : {}) } }
|
|
95
|
+
: {}),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// google-chat-message-ops — the Google Chat surface's host-notice transport (SurfaceMessageOps). Core's shared
|
|
2
|
+
// room-notice paths — an approval outcome note, the judge-lift notice, a stop or cancel note — post through
|
|
3
|
+
// `SurfaceMessageOps.postText(channelId, text)`. On Google Chat the `channelId` is the space name the identity
|
|
4
|
+
// mapper put in the binding's target. A `replyToMessageId` anchor is not followed: Google Chat threads a reply by
|
|
5
|
+
// thread name, not by message, so the notice lands in the space as a new message.
|
|
6
|
+
//
|
|
7
|
+
// 🔴 TEXT ONLY. `editMessage` refuses: no card posts on this surface yet, so there is no card to close.
|
|
8
|
+
|
|
9
|
+
import { markNotApplied, type SurfaceMessageOps } from "@open-everywhere/core/agent/adapter-api.js";
|
|
10
|
+
import { googleChatPolicy, type GoogleChatPolicy } from "../policy.js";
|
|
11
|
+
import { createGoogleChatMessage, type GoogleChatPostDeps } from "./google-chat-rest.js";
|
|
12
|
+
|
|
13
|
+
export interface GoogleChatNoticeDeps extends GoogleChatPostDeps {
|
|
14
|
+
readonly policy?: () => GoogleChatPolicy;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Post one host notice into a space. Throws a token-free error when it cannot. */
|
|
18
|
+
export async function postGoogleChatNotice(channelId: string, text: string, deps: GoogleChatNoticeDeps = {}): Promise<string> {
|
|
19
|
+
const policy = (deps.policy ?? googleChatPolicy)();
|
|
20
|
+
if (!policy.enabled) throw markNotApplied(new Error("the Google Chat surface is turned off."));
|
|
21
|
+
return createGoogleChatMessage({ space: channelId, text }, deps);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const googleChatMessageOps: SurfaceMessageOps = {
|
|
25
|
+
platform: "google-chat",
|
|
26
|
+
postText: (channelId, text) => postGoogleChatNotice(channelId, text),
|
|
27
|
+
async editMessage() {
|
|
28
|
+
throw markNotApplied(new Error("editing a Google Chat message is not built yet."));
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The Google Chat UnifiedWakeOrigin codec. An origin is STORED DATA (the durable wake map), so this validator is a
|
|
2
|
+
// compatibility contract with rows already on disk: it tolerates unknown extra fields, and it refuses any row whose
|
|
3
|
+
// space, thread or message name is not Google's grammar, because the post arm builds its URL from them.
|
|
4
|
+
//
|
|
5
|
+
// THE CONVERSATION: `threadRef` keys on (space, thread). Every message in a Google Chat space carries a thread
|
|
6
|
+
// name, even a top-level one, so the thread is part of the identity and placement lives inside it (no
|
|
7
|
+
// `placementRef`). Only a positive `threadReply: true` makes an origin thread-scoped.
|
|
8
|
+
|
|
9
|
+
import type { UnifiedWakeOrigin, WakeOriginCodec } from "@open-everywhere/core/agent/adapter-api.js";
|
|
10
|
+
import { isMessageNameIn, isSpaceName, isThreadNameIn } from "./google-chat-rest.js";
|
|
11
|
+
|
|
12
|
+
export type GoogleChatOrigin = Extract<UnifiedWakeOrigin, { platform: "google-chat" }>;
|
|
13
|
+
|
|
14
|
+
const WORKSPACE_SEGMENT = /^[a-z0-9][a-z0-9-]{0,62}$/u;
|
|
15
|
+
|
|
16
|
+
/** A task windowId is `[^:]+` (core channels/window-key.ts); Google names hold no colon, but fold one anyway. */
|
|
17
|
+
const noColon = (s: string): string => s.replace(/:/g, "_");
|
|
18
|
+
|
|
19
|
+
export const googleChatWakeOriginCodec: WakeOriginCodec<GoogleChatOrigin> = {
|
|
20
|
+
validate: (o) => {
|
|
21
|
+
if (typeof o.workspace !== "string" || !WORKSPACE_SEGMENT.test(o.workspace)) return false;
|
|
22
|
+
if (!isSpaceName(o.space)) return false;
|
|
23
|
+
if (o.thread !== undefined && !isThreadNameIn(o.thread, o.space)) return false;
|
|
24
|
+
if (o.messageName !== undefined && !isMessageNameIn(o.messageName, o.space)) return false;
|
|
25
|
+
// A thread reply with no thread is not a shape the front mints.
|
|
26
|
+
if (o.threadReply !== undefined && (o.threadReply !== true || o.thread === undefined)) return false;
|
|
27
|
+
return true;
|
|
28
|
+
},
|
|
29
|
+
threadRef: (o) => ({ channelKey: noColon(`google-chat/${o.space}`), threadKey: noColon(o.thread ?? o.space) }),
|
|
30
|
+
isThreadScoped: (o) => o.threadReply === true,
|
|
31
|
+
containerId: (o) => o.space,
|
|
32
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// google-chat-post-arm — the Google Chat surface's arm of the post-arms registry. One verb: answer the event that
|
|
2
|
+
// woke the seat, in the same space and thread, as the app (google-chat-rest.ts). The space and thread come from
|
|
3
|
+
// the stored origin, which the front froze from the token-verified event; the model never names one.
|
|
4
|
+
//
|
|
5
|
+
// 🔴 THE POLICY IS RE-READ AT SEND TIME. A surface turned off between the wake and the reply sends nothing, and
|
|
6
|
+
// an origin minted under another workspace segment is refused.
|
|
7
|
+
// NO EDIT, NO REACTION yet: the arm registers neither, and the capability manifest says so.
|
|
8
|
+
|
|
9
|
+
import { markNotApplied, type PostArm, type UnifiedReplyDeps, type UnifiedWakeOrigin } from "@open-everywhere/core/agent/adapter-api.js";
|
|
10
|
+
import { googleChatPolicy, type GoogleChatPolicy } from "../policy.js";
|
|
11
|
+
import { createGoogleChatMessage, type GoogleChatPostDeps } from "./google-chat-rest.js";
|
|
12
|
+
import { googleChatWakeOriginCodec, type GoogleChatOrigin } from "./google-chat-origin-codec.js";
|
|
13
|
+
|
|
14
|
+
export interface GoogleChatReplyDeps extends GoogleChatPostDeps {
|
|
15
|
+
readonly policy?: () => GoogleChatPolicy;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Send one reply for a stored origin. Throws a token-free error when the reply did not go out. */
|
|
19
|
+
export async function sendGoogleChatReply(origin: GoogleChatOrigin, text: string, deps: GoogleChatReplyDeps = {}): Promise<string> {
|
|
20
|
+
if (!googleChatWakeOriginCodec.validate(origin as unknown as Record<string, unknown>)) {
|
|
21
|
+
throw markNotApplied(new Error("reply couldn't post to Google Chat — the stored reply target is not a valid Google Chat origin."));
|
|
22
|
+
}
|
|
23
|
+
const policy = (deps.policy ?? googleChatPolicy)();
|
|
24
|
+
if (!policy.enabled) throw markNotApplied(new Error("reply couldn't post to Google Chat — the surface is turned off in agent/google-chat/policy.ts."));
|
|
25
|
+
if (origin.workspace !== policy.workspace) {
|
|
26
|
+
throw markNotApplied(new Error("reply couldn't post to Google Chat — the stored reply target belongs to another workspace."));
|
|
27
|
+
}
|
|
28
|
+
return createGoogleChatMessage({ space: origin.space, ...(origin.thread !== undefined ? { thread: origin.thread } : {}), text }, deps);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const googleChatPostArm: PostArm = {
|
|
32
|
+
platform: "google-chat",
|
|
33
|
+
dispatchUnified: (origin: UnifiedWakeOrigin, text: string, _deps: UnifiedReplyDeps) => sendGoogleChatReply(origin as GoogleChatOrigin, text),
|
|
34
|
+
describeRedriveTarget: (origin) => `Google Chat ${(origin as GoogleChatOrigin).space}`,
|
|
35
|
+
redriveContainerId: (origin) => (origin as GoogleChatOrigin).space,
|
|
36
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// google-chat-rest — the ONE outbound call of the Google Chat surface: create a message in a space, as the app,
|
|
2
|
+
// optionally inside a thread. The host is pinned (`chat.googleapis.com`), the space and thread names are
|
|
3
|
+
// validated against Google's resource grammar before they reach a URL, and errors carry the HTTP status only.
|
|
4
|
+
//
|
|
5
|
+
// The app can post only into a space it is a member of, so this call cannot reach a space nobody added the app
|
|
6
|
+
// to. A text longer than one Chat message is sent as consecutive messages in the same thread.
|
|
7
|
+
|
|
8
|
+
import { friendlyOutboundText, markNotApplied } from "@open-everywhere/core/agent/adapter-api.js";
|
|
9
|
+
import { googleChatAccessToken, type GoogleChatTokenDeps } from "./google-chat-auth.js";
|
|
10
|
+
|
|
11
|
+
const CHAT_API = "https://chat.googleapis.com/v1";
|
|
12
|
+
/** One Chat message's text limit is 4,096 characters; leave room for nothing else. */
|
|
13
|
+
export const GOOGLE_CHAT_MAX_TEXT = 4000;
|
|
14
|
+
|
|
15
|
+
const SPACE_NAME = /^spaces\/[A-Za-z0-9_-]{1,128}$/u;
|
|
16
|
+
const THREAD_NAME = /^spaces\/[A-Za-z0-9_-]{1,128}\/threads\/[A-Za-z0-9_.-]{1,128}$/u;
|
|
17
|
+
const MESSAGE_NAME = /^spaces\/[A-Za-z0-9_-]{1,128}\/messages\/[A-Za-z0-9_.-]{1,128}$/u;
|
|
18
|
+
|
|
19
|
+
export function isSpaceName(v: unknown): v is string {
|
|
20
|
+
return typeof v === "string" && SPACE_NAME.test(v);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A thread name that belongs to `space` (a thread of another space is not a thread of this one). */
|
|
24
|
+
export function isThreadNameIn(v: unknown, space: string): v is string {
|
|
25
|
+
return typeof v === "string" && THREAD_NAME.test(v) && v.startsWith(`${space}/threads/`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isMessageNameIn(v: unknown, space: string): v is string {
|
|
29
|
+
return typeof v === "string" && MESSAGE_NAME.test(v) && v.startsWith(`${space}/messages/`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Split `text` into Chat-sized pieces, preferring a newline near the limit, never inside a surrogate pair. */
|
|
33
|
+
export function splitGoogleChatText(text: string, max = GOOGLE_CHAT_MAX_TEXT): readonly string[] {
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
let rest = text;
|
|
36
|
+
while (rest.length > max) {
|
|
37
|
+
const nl = rest.lastIndexOf("\n", max);
|
|
38
|
+
let cut = nl > max / 2 ? nl : max;
|
|
39
|
+
// Never cut through a surrogate pair: a lone half is not valid text, and Google may refuse the message.
|
|
40
|
+
const before = rest.charCodeAt(cut - 1);
|
|
41
|
+
if (before >= 0xd800 && before <= 0xdbff) cut -= 1;
|
|
42
|
+
out.push(rest.slice(0, cut));
|
|
43
|
+
rest = rest.slice(cut).replace(/^\n/u, "");
|
|
44
|
+
}
|
|
45
|
+
out.push(rest);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GoogleChatPostDeps extends GoogleChatTokenDeps {}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Post `text` into `space`, threaded under `thread` when given (a thread Google no longer knows falls back to
|
|
53
|
+
* a new thread). Returns the LAST created message name. A pre-flight refusal is definitive (`markNotApplied`).
|
|
54
|
+
*/
|
|
55
|
+
export async function createGoogleChatMessage(
|
|
56
|
+
input: { readonly space: string; readonly thread?: string; readonly text: string },
|
|
57
|
+
deps: GoogleChatPostDeps = {},
|
|
58
|
+
): Promise<string> {
|
|
59
|
+
if (!isSpaceName(input.space)) throw markNotApplied(new Error("the Google Chat space name is not valid."));
|
|
60
|
+
if (input.thread !== undefined && !isThreadNameIn(input.thread, input.space)) {
|
|
61
|
+
throw markNotApplied(new Error("the Google Chat thread does not belong to the space."));
|
|
62
|
+
}
|
|
63
|
+
if (input.text.trim() === "") throw markNotApplied(new Error("a Google Chat message needs text."));
|
|
64
|
+
// THE FRIENDLY-NAMES GUARD (OPE-2482), on the one Google Chat write: every raw id renders as a name or
|
|
65
|
+
// plain words before the text is split and sent, so a post outside the registered arms is covered too.
|
|
66
|
+
const text = await friendlyOutboundText(input.text, "google-chat");
|
|
67
|
+
const token = await googleChatAccessToken(deps);
|
|
68
|
+
const doFetch = deps.fetch ?? fetch;
|
|
69
|
+
let thread = input.thread;
|
|
70
|
+
let last = "";
|
|
71
|
+
for (const piece of splitGoogleChatText(text)) {
|
|
72
|
+
const url = `${CHAT_API}/${input.space}/messages${thread !== undefined ? "?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" : ""}`;
|
|
73
|
+
const res = await doFetch(url, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8" },
|
|
76
|
+
body: JSON.stringify({ text: piece, ...(thread !== undefined ? { thread: { name: thread } } : {}) }),
|
|
77
|
+
});
|
|
78
|
+
if (!res.ok) throw new Error(`Google Chat refused the message (HTTP ${res.status}).`);
|
|
79
|
+
const body = (await res.json().catch(() => ({}))) as { name?: unknown; thread?: { name?: unknown } };
|
|
80
|
+
if (typeof body.name !== "string") throw new Error("Google Chat returned no message name.");
|
|
81
|
+
last = body.name;
|
|
82
|
+
// The pieces after the first follow the first one's thread, so a long answer stays together.
|
|
83
|
+
const created = body.thread?.name;
|
|
84
|
+
if (thread === undefined && isThreadNameIn(created, input.space)) thread = created;
|
|
85
|
+
}
|
|
86
|
+
return last;
|
|
87
|
+
}
|