@kodelyth/googlechat 2026.5.39 → 2026.5.42

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.
Files changed (91) hide show
  1. package/api.ts +3 -0
  2. package/channel-config-api.ts +1 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/config-api.ts +2 -0
  5. package/contract-api.ts +5 -0
  6. package/dist/actions-YK1wn4ed.js +160 -0
  7. package/dist/api-BkZX4VNX.js +633 -0
  8. package/dist/api.js +3 -0
  9. package/dist/channel-DFZdjXD6.js +584 -0
  10. package/dist/channel-config-api.js +6 -0
  11. package/dist/channel-plugin-api.js +2 -0
  12. package/dist/channel.runtime-en3RNg9S.js +998 -0
  13. package/dist/contract-api.js +3 -0
  14. package/dist/doctor-contract-8SF6XoKj.js +151 -0
  15. package/dist/doctor-contract-api.js +2 -0
  16. package/dist/index.js +22 -0
  17. package/dist/runtime-api-DUH2Cg-0.js +29 -0
  18. package/dist/runtime-api.js +2 -0
  19. package/dist/secret-contract-DWX4ikgT.js +99 -0
  20. package/dist/secret-contract-api.js +2 -0
  21. package/dist/setup-entry.js +15 -0
  22. package/dist/setup-plugin-api.js +75 -0
  23. package/dist/setup-surface-B3Fa7XRx.js +321 -0
  24. package/dist/test-api.js +3 -0
  25. package/doctor-contract-api.ts +1 -0
  26. package/index.ts +20 -0
  27. package/klaw.plugin.json +2 -967
  28. package/package.json +4 -4
  29. package/runtime-api.ts +55 -0
  30. package/secret-contract-api.ts +5 -0
  31. package/setup-entry.ts +13 -0
  32. package/setup-plugin-api.ts +3 -0
  33. package/src/accounts.ts +181 -0
  34. package/src/actions.test.ts +289 -0
  35. package/src/actions.ts +227 -0
  36. package/src/api.ts +316 -0
  37. package/src/approval-auth.test.ts +24 -0
  38. package/src/approval-auth.ts +32 -0
  39. package/src/auth.ts +218 -0
  40. package/src/channel-config.test.ts +39 -0
  41. package/src/channel.adapters.ts +340 -0
  42. package/src/channel.deps.runtime.ts +29 -0
  43. package/src/channel.runtime.ts +17 -0
  44. package/src/channel.setup.ts +98 -0
  45. package/src/channel.test.ts +784 -0
  46. package/src/channel.ts +277 -0
  47. package/src/config-schema.test.ts +31 -0
  48. package/src/config-schema.ts +3 -0
  49. package/src/doctor-contract.test.ts +75 -0
  50. package/src/doctor-contract.ts +182 -0
  51. package/src/doctor.ts +57 -0
  52. package/src/gateway.ts +63 -0
  53. package/src/google-auth.runtime.test.ts +543 -0
  54. package/src/google-auth.runtime.ts +568 -0
  55. package/src/group-policy.ts +17 -0
  56. package/src/monitor-access.test.ts +491 -0
  57. package/src/monitor-access.ts +465 -0
  58. package/src/monitor-durable.test.ts +39 -0
  59. package/src/monitor-durable.ts +23 -0
  60. package/src/monitor-reply-delivery.ts +156 -0
  61. package/src/monitor-routing.ts +65 -0
  62. package/src/monitor-types.ts +33 -0
  63. package/src/monitor-webhook.test.ts +587 -0
  64. package/src/monitor-webhook.ts +303 -0
  65. package/src/monitor.reply-delivery.test.ts +144 -0
  66. package/src/monitor.test.ts +159 -0
  67. package/src/monitor.ts +527 -0
  68. package/src/monitor.webhook-routing.test.ts +257 -0
  69. package/src/runtime.ts +9 -0
  70. package/src/secret-contract.test.ts +60 -0
  71. package/src/secret-contract.ts +161 -0
  72. package/src/setup-core.ts +40 -0
  73. package/src/setup-surface.ts +243 -0
  74. package/src/setup.test.ts +619 -0
  75. package/src/targets.test.ts +453 -0
  76. package/src/targets.ts +66 -0
  77. package/src/types.config.ts +3 -0
  78. package/src/types.ts +73 -0
  79. package/test-api.ts +2 -0
  80. package/tsconfig.json +16 -0
  81. package/api.js +0 -7
  82. package/channel-config-api.js +0 -7
  83. package/channel-plugin-api.js +0 -7
  84. package/contract-api.js +0 -7
  85. package/doctor-contract-api.js +0 -7
  86. package/index.js +0 -7
  87. package/runtime-api.js +0 -7
  88. package/secret-contract-api.js +0 -7
  89. package/setup-entry.js +0 -7
  90. package/setup-plugin-api.js +0 -7
  91. package/test-api.js +0 -7
@@ -0,0 +1,465 @@
1
+ import {
2
+ channelIngressRoutes,
3
+ createChannelIngressResolver,
4
+ defineStableChannelIngressIdentity,
5
+ } from "klaw/plugin-sdk/channel-ingress-runtime";
6
+ import type { ChannelBotLoopProtectionConfig } from "klaw/plugin-sdk/config-contracts";
7
+ import {
8
+ normalizeLowercaseStringOrEmpty,
9
+ normalizeOptionalString,
10
+ normalizeStringEntries,
11
+ } from "klaw/plugin-sdk/string-coerce-runtime";
12
+ import {
13
+ GROUP_POLICY_BLOCKED_LABEL,
14
+ createChannelPairingController,
15
+ isDangerousNameMatchingEnabled,
16
+ resolveAllowlistProviderRuntimeGroupPolicy,
17
+ resolveDefaultGroupPolicy,
18
+ warnMissingProviderGroupPolicyFallbackOnce,
19
+ type KlawConfig,
20
+ } from "../runtime-api.js";
21
+ import type { ResolvedGoogleChatAccount } from "./accounts.js";
22
+ import { sendGoogleChatMessage } from "./api.js";
23
+ import type { GoogleChatCoreRuntime } from "./monitor-types.js";
24
+ import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js";
25
+
26
+ function normalizeUserId(raw?: string | null): string {
27
+ const trimmed = normalizeOptionalString(raw) ?? "";
28
+ if (!trimmed) {
29
+ return "";
30
+ }
31
+ return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
32
+ }
33
+
34
+ const GOOGLECHAT_EMAIL_KIND = "plugin:googlechat-email" as const;
35
+
36
+ function normalizeEntryValue(raw?: string | null): string {
37
+ return normalizeLowercaseStringOrEmpty(raw ?? "");
38
+ }
39
+
40
+ function normalizeGoogleChatStableEntry(entry: string): string | null {
41
+ const withoutProvider = normalizeEntryValue(entry).replace(
42
+ /^(googlechat|google-chat|gchat):/i,
43
+ "",
44
+ );
45
+ if (!withoutProvider) {
46
+ return null;
47
+ }
48
+ return withoutProvider.startsWith("users/") ? normalizeUserId(withoutProvider) : withoutProvider;
49
+ }
50
+
51
+ function normalizeGoogleChatEmailEntry(entry: string): string | null {
52
+ const withoutProvider = normalizeEntryValue(entry).replace(
53
+ /^(googlechat|google-chat|gchat):/i,
54
+ "",
55
+ );
56
+ if (withoutProvider.startsWith("users/")) {
57
+ return null;
58
+ }
59
+ const stable = normalizeGoogleChatStableEntry(entry);
60
+ return stable?.includes("@") ? stable : null;
61
+ }
62
+
63
+ const googleChatIngressIdentity = defineStableChannelIngressIdentity({
64
+ key: "sender-id",
65
+ normalizeEntry: normalizeGoogleChatStableEntry,
66
+ normalizeSubject: normalizeUserId,
67
+ aliases: [
68
+ {
69
+ key: "email",
70
+ kind: GOOGLECHAT_EMAIL_KIND,
71
+ normalizeEntry: normalizeGoogleChatEmailEntry,
72
+ normalizeSubject: normalizeEntryValue,
73
+ dangerous: true,
74
+ },
75
+ ],
76
+ isWildcardEntry: (entry) => normalizeEntryValue(entry) === "*",
77
+ resolveEntryId: ({ entryIndex, fieldKey }) =>
78
+ fieldKey === "stableId"
79
+ ? `entry-${entryIndex + 1}:user`
80
+ : `entry-${entryIndex + 1}:${fieldKey}`,
81
+ });
82
+
83
+ type GoogleChatGroupEntry = {
84
+ requireMention?: boolean;
85
+ enabled?: boolean;
86
+ botLoopProtection?: ChannelBotLoopProtectionConfig;
87
+ users?: Array<string | number>;
88
+ systemPrompt?: string;
89
+ };
90
+
91
+ function resolveGroupConfig(params: {
92
+ groupId: string;
93
+ groupName?: string | null;
94
+ groups?: Record<string, GoogleChatGroupEntry>;
95
+ }) {
96
+ const { groupId, groupName, groups } = params;
97
+ const entries = groups ?? {};
98
+ const keys = Object.keys(entries);
99
+ if (keys.length === 0) {
100
+ return { entry: undefined, allowlistConfigured: false, deprecatedNameMatch: false };
101
+ }
102
+ const entry = entries[groupId];
103
+ const normalizedGroupName = normalizeLowercaseStringOrEmpty(groupName ?? "");
104
+ const deprecatedNameMatch =
105
+ !entry &&
106
+ Boolean(
107
+ groupName &&
108
+ keys.some((key) => {
109
+ const trimmed = key.trim();
110
+ if (!trimmed || trimmed === "*" || /^spaces\//i.test(trimmed)) {
111
+ return false;
112
+ }
113
+ return (
114
+ trimmed === groupName || normalizeLowercaseStringOrEmpty(trimmed) === normalizedGroupName
115
+ );
116
+ }),
117
+ );
118
+ const fallback = entries["*"];
119
+ return {
120
+ entry: deprecatedNameMatch ? undefined : (entry ?? fallback),
121
+ allowlistConfigured: true,
122
+ fallback,
123
+ deprecatedNameMatch,
124
+ };
125
+ }
126
+
127
+ function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: string | null) {
128
+ const mentionAnnotations = annotations.filter((entry) => entry.type === "USER_MENTION");
129
+ const hasAnyMention = mentionAnnotations.length > 0;
130
+ const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]);
131
+ const wasMentioned = mentionAnnotations.some((entry) => {
132
+ const userName = entry.userMention?.user?.name;
133
+ if (!userName) {
134
+ return false;
135
+ }
136
+ if (botTargets.has(userName)) {
137
+ return true;
138
+ }
139
+ return normalizeUserId(userName) === "app";
140
+ });
141
+ return { hasAnyMention, wasMentioned };
142
+ }
143
+
144
+ const warnedDeprecatedUsersEmailAllowFrom = new Set<string>();
145
+ const warnedMutableGroupKeys = new Set<string>();
146
+
147
+ function warnDeprecatedUsersEmailEntries(logVerbose: (message: string) => void, entries: string[]) {
148
+ const deprecated = entries
149
+ .map((v) => normalizeOptionalString(v))
150
+ .filter((v): v is string => Boolean(v))
151
+ .filter((v) => /^users\/.+@.+/i.test(v));
152
+ if (deprecated.length === 0) {
153
+ return;
154
+ }
155
+ const key = deprecated
156
+ .map((v) => normalizeLowercaseStringOrEmpty(v))
157
+ .toSorted((a, b) => a.localeCompare(b))
158
+ .join(",");
159
+ if (warnedDeprecatedUsersEmailAllowFrom.has(key)) {
160
+ return;
161
+ }
162
+ warnedDeprecatedUsersEmailAllowFrom.add(key);
163
+ logVerbose(
164
+ `Deprecated allowFrom entry detected: "users/<email>" is no longer treated as an email allowlist. Use raw email (alice@example.com) or immutable user id (users/<id>). entries=${deprecated.join(", ")}`,
165
+ );
166
+ }
167
+
168
+ function warnMutableGroupKeysConfigured(
169
+ logVerbose: (message: string) => void,
170
+ groups?: Record<string, GoogleChatGroupEntry>,
171
+ ) {
172
+ const mutableKeys = Object.keys(groups ?? {})
173
+ .map((key) => key.trim())
174
+ .filter((key) => key && key !== "*" && !/^spaces\//i.test(key));
175
+ if (mutableKeys.length === 0) {
176
+ return;
177
+ }
178
+ const warningKey = mutableKeys
179
+ .map((key) => normalizeLowercaseStringOrEmpty(key))
180
+ .toSorted((a, b) => a.localeCompare(b))
181
+ .join(",");
182
+ if (warnedMutableGroupKeys.has(warningKey)) {
183
+ return;
184
+ }
185
+ warnedMutableGroupKeys.add(warningKey);
186
+ logVerbose(
187
+ `Deprecated Google Chat group key detected: group routing now requires stable space ids (spaces/<spaceId>). Update channels.googlechat.groups keys: ${mutableKeys.join(", ")}`,
188
+ );
189
+ }
190
+
191
+ export async function applyGoogleChatInboundAccessPolicy(params: {
192
+ account: ResolvedGoogleChatAccount;
193
+ config: KlawConfig;
194
+ core: GoogleChatCoreRuntime;
195
+ space: GoogleChatSpace;
196
+ message: GoogleChatMessage;
197
+ isGroup: boolean;
198
+ senderId: string;
199
+ senderName: string;
200
+ senderEmail?: string;
201
+ rawBody: string;
202
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
203
+ logVerbose: (message: string) => void;
204
+ }): Promise<
205
+ | {
206
+ ok: true;
207
+ commandAuthorized: boolean | undefined;
208
+ effectiveWasMentioned: boolean | undefined;
209
+ groupBotLoopProtection: ChannelBotLoopProtectionConfig | undefined;
210
+ groupSystemPrompt: string | undefined;
211
+ }
212
+ | { ok: false }
213
+ > {
214
+ const {
215
+ account,
216
+ config,
217
+ core,
218
+ space,
219
+ message,
220
+ isGroup,
221
+ senderId,
222
+ senderName,
223
+ senderEmail,
224
+ rawBody,
225
+ statusSink,
226
+ logVerbose,
227
+ } = params;
228
+ const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
229
+ const spaceId = space.name ?? "";
230
+ const pairing = createChannelPairingController({
231
+ core,
232
+ channel: "googlechat",
233
+ accountId: account.accountId,
234
+ });
235
+
236
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
237
+ const { groupPolicy, providerMissingFallbackApplied } =
238
+ resolveAllowlistProviderRuntimeGroupPolicy({
239
+ providerConfigPresent: config.channels?.googlechat !== undefined,
240
+ groupPolicy: account.config.groupPolicy,
241
+ defaultGroupPolicy,
242
+ });
243
+ warnMissingProviderGroupPolicyFallbackOnce({
244
+ providerMissingFallbackApplied,
245
+ providerKey: "googlechat",
246
+ accountId: account.accountId,
247
+ blockedLabel: GROUP_POLICY_BLOCKED_LABEL.space,
248
+ log: logVerbose,
249
+ });
250
+ warnMutableGroupKeysConfigured(logVerbose, account.config.groups ?? undefined);
251
+ const groupConfigResolved = resolveGroupConfig({
252
+ groupId: spaceId,
253
+ groupName: space.displayName ?? null,
254
+ groups: account.config.groups ?? undefined,
255
+ });
256
+ const groupEntry = groupConfigResolved.entry;
257
+ const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
258
+ let effectiveWasMentioned: boolean | undefined;
259
+ const dmPolicy = account.config.dm?.policy ?? "pairing";
260
+ const rawConfigAllowFrom = normalizeStringEntries(account.config.dm?.allowFrom);
261
+ const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
262
+ const groupActivation = (() => {
263
+ if (!isGroup) {
264
+ return undefined;
265
+ }
266
+ const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
267
+ const mentionInfo = extractMentionInfo(message.annotations ?? [], account.config.botUser);
268
+ return {
269
+ requireMention,
270
+ allowTextCommands: core.channel.commands.shouldHandleTextCommands({
271
+ cfg: config,
272
+ surface: "googlechat",
273
+ }),
274
+ hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
275
+ wasMentioned: mentionInfo.wasMentioned,
276
+ hasAnyMention: mentionInfo.hasAnyMention,
277
+ };
278
+ })();
279
+ const command = {
280
+ hasControlCommand: groupActivation?.hasControlCommand ?? shouldComputeAuth,
281
+ groupOwnerAllowFrom: "none" as const,
282
+ };
283
+ const groupAllowFrom = normalizeStringEntries(groupUsers);
284
+ const senderGroupPolicy =
285
+ groupConfigResolved.allowlistConfigured && groupAllowFrom.length === 0
286
+ ? groupPolicy
287
+ : groupPolicy === "disabled"
288
+ ? "disabled"
289
+ : groupAllowFrom.length > 0
290
+ ? "allowlist"
291
+ : "open";
292
+ const route = channelIngressRoutes(
293
+ isGroup &&
294
+ groupPolicy !== "disabled" &&
295
+ groupEntry?.enabled === false && {
296
+ id: "googlechat:space",
297
+ enabled: false,
298
+ matched: true,
299
+ matchId: "googlechat-space",
300
+ blockReason: "route_disabled",
301
+ },
302
+ isGroup &&
303
+ groupPolicy === "allowlist" &&
304
+ groupEntry?.enabled !== false &&
305
+ !groupConfigResolved.allowlistConfigured && {
306
+ id: "googlechat:space",
307
+ allowed: false,
308
+ blockReason: "empty_allowlist",
309
+ },
310
+ isGroup &&
311
+ groupPolicy === "allowlist" &&
312
+ groupEntry?.enabled !== false &&
313
+ groupConfigResolved.allowlistConfigured && {
314
+ id: "googlechat:space",
315
+ senderPolicy: "deny-when-empty" as const,
316
+ ...(groupEntry ? { senderAllowFromSource: "effective-group" as const } : {}),
317
+ allowed: Boolean(groupEntry),
318
+ matchId: "googlechat-space",
319
+ blockReason: groupEntry ? "sender_empty_allowlist" : "route_not_allowlisted",
320
+ },
321
+ );
322
+ const resolvedAccess = await createChannelIngressResolver({
323
+ channelId: "googlechat",
324
+ accountId: account.accountId,
325
+ identity: googleChatIngressIdentity,
326
+ cfg: config,
327
+ readStoreAllowFrom: pairing.readAllowFromStore,
328
+ }).message({
329
+ subject: {
330
+ stableId: senderId,
331
+ aliases: { email: senderEmail },
332
+ },
333
+ conversation: {
334
+ kind: isGroup ? "group" : "direct",
335
+ id: spaceId,
336
+ },
337
+ route,
338
+ allowFrom: rawConfigAllowFrom,
339
+ groupAllowFrom,
340
+ dmPolicy,
341
+ groupPolicy: senderGroupPolicy,
342
+ policy: {
343
+ groupAllowFromFallbackToAllowFrom: false,
344
+ mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
345
+ ...(groupActivation
346
+ ? {
347
+ activation: {
348
+ requireMention: groupActivation.requireMention,
349
+ allowTextCommands: groupActivation.allowTextCommands,
350
+ },
351
+ }
352
+ : {}),
353
+ },
354
+ ...(groupActivation == null
355
+ ? {}
356
+ : {
357
+ mentionFacts: {
358
+ canDetectMention: true,
359
+ wasMentioned: groupActivation.wasMentioned,
360
+ hasAnyMention: groupActivation.hasAnyMention,
361
+ implicitMentionKinds: [],
362
+ },
363
+ }),
364
+ command,
365
+ });
366
+ const senderAccess = resolvedAccess.senderAccess;
367
+ const commandAuthorized = resolvedAccess.commandAccess.requested
368
+ ? resolvedAccess.commandAccess.authorized
369
+ : undefined;
370
+
371
+ if (isGroup) {
372
+ if (groupConfigResolved.deprecatedNameMatch) {
373
+ logVerbose(`drop group message (deprecated mutable group key matched, space=${spaceId})`);
374
+ return { ok: false };
375
+ }
376
+ const routeBlockReason = resolvedAccess.routeAccess.reason;
377
+ if (routeBlockReason && routeBlockReason !== "sender_empty_allowlist") {
378
+ if (routeBlockReason === "empty_allowlist") {
379
+ logVerbose(`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`);
380
+ } else if (routeBlockReason === "route_not_allowlisted") {
381
+ logVerbose(`drop group message (not allowlisted, space=${spaceId})`);
382
+ } else if (routeBlockReason === "route_disabled") {
383
+ logVerbose(`drop group message (space disabled, space=${spaceId})`);
384
+ }
385
+ return { ok: false };
386
+ }
387
+
388
+ if (senderAccess.effectiveGroupAllowFrom.length > 0 && senderAccess.decision !== "allow") {
389
+ warnDeprecatedUsersEmailEntries(logVerbose, senderAccess.effectiveGroupAllowFrom);
390
+ logVerbose(`drop group message (sender not allowed, ${senderId})`);
391
+ return { ok: false };
392
+ }
393
+ }
394
+
395
+ const effectiveAllowFrom = senderAccess.effectiveAllowFrom;
396
+ warnDeprecatedUsersEmailEntries(logVerbose, effectiveAllowFrom);
397
+
398
+ if (isGroup && resolvedAccess.activationAccess.ran) {
399
+ effectiveWasMentioned = resolvedAccess.activationAccess.effectiveWasMentioned;
400
+ if (resolvedAccess.activationAccess.shouldSkip) {
401
+ logVerbose(`drop group message (mention required, space=${spaceId})`);
402
+ return { ok: false };
403
+ }
404
+ }
405
+
406
+ if (isGroup && senderAccess.decision !== "allow") {
407
+ const reason =
408
+ resolvedAccess.ingress.reasonCode === "route_sender_empty"
409
+ ? "groupPolicy=allowlist (empty allowlist)"
410
+ : senderAccess.reasonCode;
411
+ logVerbose(`drop group message (sender policy blocked, reason=${reason}, space=${spaceId})`);
412
+ return { ok: false };
413
+ }
414
+
415
+ if (!isGroup) {
416
+ if (account.config.dm?.enabled === false) {
417
+ logVerbose(`Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`);
418
+ return { ok: false };
419
+ }
420
+
421
+ if (senderAccess.decision !== "allow") {
422
+ if (senderAccess.decision === "pairing") {
423
+ await pairing.issueChallenge({
424
+ senderId,
425
+ senderIdLine: `Your Google Chat user id: ${senderId}`,
426
+ meta: { name: senderName || undefined, email: senderEmail },
427
+ onCreated: () => {
428
+ logVerbose(`googlechat pairing request sender=${senderId}`);
429
+ },
430
+ sendPairingReply: async (text) => {
431
+ await sendGoogleChatMessage({
432
+ account,
433
+ space: spaceId,
434
+ text,
435
+ });
436
+ statusSink?.({ lastOutboundAt: Date.now() });
437
+ },
438
+ onReplyError: (err) => {
439
+ logVerbose(`pairing reply failed for ${senderId}: ${String(err)}`);
440
+ },
441
+ });
442
+ } else {
443
+ logVerbose(`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`);
444
+ }
445
+ return { ok: false };
446
+ }
447
+ }
448
+
449
+ if (
450
+ isGroup &&
451
+ core.channel.commands.isControlCommandMessage(rawBody, config) &&
452
+ commandAuthorized !== true
453
+ ) {
454
+ logVerbose(`googlechat: drop control command from ${senderId}`);
455
+ return { ok: false };
456
+ }
457
+
458
+ return {
459
+ ok: true,
460
+ commandAuthorized,
461
+ effectiveWasMentioned,
462
+ groupBotLoopProtection: groupEntry?.botLoopProtection,
463
+ groupSystemPrompt: normalizeOptionalString(groupEntry?.systemPrompt),
464
+ };
465
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveGoogleChatDurableReplyOptions } from "./monitor-durable.js";
3
+
4
+ describe("resolveGoogleChatDurableReplyOptions", () => {
5
+ it("enables durable final delivery when no typing preview is active", () => {
6
+ expect(
7
+ resolveGoogleChatDurableReplyOptions({
8
+ payload: { text: "hello", replyToId: "thread-1" },
9
+ infoKind: "final",
10
+ spaceId: "spaces/AAA",
11
+ }),
12
+ ).toEqual({
13
+ to: "spaces/AAA",
14
+ replyToId: "thread-1",
15
+ threadId: "thread-1",
16
+ });
17
+ });
18
+
19
+ it("keeps typing preview delivery on the legacy edit path", () => {
20
+ expect(
21
+ resolveGoogleChatDurableReplyOptions({
22
+ payload: { text: "hello" },
23
+ infoKind: "final",
24
+ spaceId: "spaces/AAA",
25
+ typingMessageName: "spaces/AAA/messages/typing",
26
+ }),
27
+ ).toBe(false);
28
+ });
29
+
30
+ it("does not durable-deliver non-final chunks", () => {
31
+ expect(
32
+ resolveGoogleChatDurableReplyOptions({
33
+ payload: { text: "hello" },
34
+ infoKind: "block",
35
+ spaceId: "spaces/AAA",
36
+ }),
37
+ ).toBe(false);
38
+ });
39
+ });
@@ -0,0 +1,23 @@
1
+ import type { ReplyPayload } from "klaw/plugin-sdk/reply-runtime";
2
+
3
+ export type GoogleChatDurableReplyOptions = {
4
+ to: string;
5
+ replyToId?: string;
6
+ threadId?: string;
7
+ };
8
+
9
+ export function resolveGoogleChatDurableReplyOptions(params: {
10
+ payload: ReplyPayload;
11
+ infoKind: string;
12
+ spaceId: string;
13
+ typingMessageName?: string;
14
+ }): GoogleChatDurableReplyOptions | false {
15
+ if (params.infoKind !== "final" || params.typingMessageName) {
16
+ return false;
17
+ }
18
+ const threadId = params.payload.replyToId?.trim() || undefined;
19
+ return {
20
+ to: params.spaceId,
21
+ ...(threadId ? { replyToId: threadId, threadId } : {}),
22
+ };
23
+ }
@@ -0,0 +1,156 @@
1
+ import {
2
+ deliverTextOrMediaReply,
3
+ resolveSendableOutboundReplyParts,
4
+ } from "klaw/plugin-sdk/reply-payload";
5
+ import type { KlawConfig } from "../runtime-api.js";
6
+ import type { ResolvedGoogleChatAccount } from "./accounts.js";
7
+ import {
8
+ deleteGoogleChatMessage,
9
+ sendGoogleChatMessage,
10
+ updateGoogleChatMessage,
11
+ uploadGoogleChatAttachment,
12
+ } from "./api.js";
13
+ import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
14
+
15
+ export async function deliverGoogleChatReply(params: {
16
+ payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
17
+ account: ResolvedGoogleChatAccount;
18
+ spaceId: string;
19
+ runtime: GoogleChatRuntimeEnv;
20
+ core: GoogleChatCoreRuntime;
21
+ config: KlawConfig;
22
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
23
+ typingMessageName?: string;
24
+ }): Promise<void> {
25
+ const { payload, account, spaceId, runtime, core, config, statusSink } = params;
26
+ // Clear this whenever the typing message is deleted or unavailable; otherwise
27
+ // text delivery can keep retrying a dead message and drop content.
28
+ let typingMessageName = params.typingMessageName;
29
+ const reply = resolveSendableOutboundReplyParts(payload);
30
+ const mediaCount = reply.mediaCount;
31
+ const hasMedia = reply.hasMedia;
32
+ const text = reply.text;
33
+ let firstTextChunk = true;
34
+ let suppressCaption = false;
35
+
36
+ if (hasMedia && typingMessageName) {
37
+ try {
38
+ await deleteGoogleChatMessage({
39
+ account,
40
+ messageName: typingMessageName,
41
+ });
42
+ typingMessageName = undefined;
43
+ } catch (err) {
44
+ runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
45
+ if (typingMessageName) {
46
+ const fallbackText = reply.hasText
47
+ ? text
48
+ : mediaCount > 1
49
+ ? "Sent attachments."
50
+ : "Sent attachment.";
51
+ try {
52
+ await updateGoogleChatMessage({
53
+ account,
54
+ messageName: typingMessageName,
55
+ text: fallbackText,
56
+ });
57
+ suppressCaption = Boolean(text.trim());
58
+ } catch (updateErr) {
59
+ runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`);
60
+ typingMessageName = undefined;
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ const chunkLimit = account.config.textChunkLimit ?? 4000;
67
+ const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId);
68
+ const sendTextMessage = async (chunk: string) => {
69
+ await sendGoogleChatMessage({
70
+ account,
71
+ space: spaceId,
72
+ text: chunk,
73
+ thread: payload.replyToId,
74
+ });
75
+ };
76
+ await deliverTextOrMediaReply({
77
+ payload,
78
+ text: suppressCaption ? "" : reply.text,
79
+ chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode),
80
+ sendText: async (chunk) => {
81
+ try {
82
+ if (firstTextChunk && typingMessageName) {
83
+ await updateGoogleChatMessage({
84
+ account,
85
+ messageName: typingMessageName,
86
+ text: chunk,
87
+ });
88
+ } else {
89
+ await sendTextMessage(chunk);
90
+ }
91
+ firstTextChunk = false;
92
+ statusSink?.({ lastOutboundAt: Date.now() });
93
+ } catch (err) {
94
+ runtime.error?.(`Google Chat message send failed: ${String(err)}`);
95
+ if (firstTextChunk && typingMessageName) {
96
+ typingMessageName = undefined;
97
+ try {
98
+ await sendTextMessage(chunk);
99
+ statusSink?.({ lastOutboundAt: Date.now() });
100
+ } catch (fallbackErr) {
101
+ runtime.error?.(`Google Chat message fallback send failed: ${String(fallbackErr)}`);
102
+ } finally {
103
+ firstTextChunk = false;
104
+ }
105
+ }
106
+ }
107
+ },
108
+ sendMedia: async ({ mediaUrl, caption }) => {
109
+ try {
110
+ const loaded = await core.channel.media.readRemoteMediaBuffer({
111
+ url: mediaUrl,
112
+ maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
113
+ });
114
+ const upload = await uploadAttachmentForReply({
115
+ account,
116
+ spaceId,
117
+ buffer: loaded.buffer,
118
+ contentType: loaded.contentType,
119
+ filename: loaded.fileName ?? "attachment",
120
+ });
121
+ if (!upload.attachmentUploadToken) {
122
+ throw new Error("missing attachment upload token");
123
+ }
124
+ await sendGoogleChatMessage({
125
+ account,
126
+ space: spaceId,
127
+ text: caption,
128
+ thread: payload.replyToId,
129
+ attachments: [
130
+ { attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName },
131
+ ],
132
+ });
133
+ statusSink?.({ lastOutboundAt: Date.now() });
134
+ } catch (err) {
135
+ runtime.error?.(`Google Chat attachment send failed: ${String(err)}`);
136
+ }
137
+ },
138
+ });
139
+ }
140
+
141
+ async function uploadAttachmentForReply(params: {
142
+ account: ResolvedGoogleChatAccount;
143
+ spaceId: string;
144
+ buffer: Buffer;
145
+ contentType?: string;
146
+ filename: string;
147
+ }) {
148
+ const { account, spaceId, buffer, contentType, filename } = params;
149
+ return await uploadGoogleChatAttachment({
150
+ account,
151
+ space: spaceId,
152
+ filename,
153
+ buffer,
154
+ contentType,
155
+ });
156
+ }