@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.
- package/api.ts +3 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +1 -0
- package/config-api.ts +2 -0
- package/contract-api.ts +5 -0
- package/dist/actions-YK1wn4ed.js +160 -0
- package/dist/api-BkZX4VNX.js +633 -0
- package/dist/api.js +3 -0
- package/dist/channel-DFZdjXD6.js +584 -0
- package/dist/channel-config-api.js +6 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/channel.runtime-en3RNg9S.js +998 -0
- package/dist/contract-api.js +3 -0
- package/dist/doctor-contract-8SF6XoKj.js +151 -0
- package/dist/doctor-contract-api.js +2 -0
- package/dist/index.js +22 -0
- package/dist/runtime-api-DUH2Cg-0.js +29 -0
- package/dist/runtime-api.js +2 -0
- package/dist/secret-contract-DWX4ikgT.js +99 -0
- package/dist/secret-contract-api.js +2 -0
- package/dist/setup-entry.js +15 -0
- package/dist/setup-plugin-api.js +75 -0
- package/dist/setup-surface-B3Fa7XRx.js +321 -0
- package/dist/test-api.js +3 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +20 -0
- package/klaw.plugin.json +2 -967
- package/package.json +4 -4
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/accounts.ts +181 -0
- package/src/actions.test.ts +289 -0
- package/src/actions.ts +227 -0
- package/src/api.ts +316 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +32 -0
- package/src/auth.ts +218 -0
- package/src/channel-config.test.ts +39 -0
- package/src/channel.adapters.ts +340 -0
- package/src/channel.deps.runtime.ts +29 -0
- package/src/channel.runtime.ts +17 -0
- package/src/channel.setup.ts +98 -0
- package/src/channel.test.ts +784 -0
- package/src/channel.ts +277 -0
- package/src/config-schema.test.ts +31 -0
- package/src/config-schema.ts +3 -0
- package/src/doctor-contract.test.ts +75 -0
- package/src/doctor-contract.ts +182 -0
- package/src/doctor.ts +57 -0
- package/src/gateway.ts +63 -0
- package/src/google-auth.runtime.test.ts +543 -0
- package/src/google-auth.runtime.ts +568 -0
- package/src/group-policy.ts +17 -0
- package/src/monitor-access.test.ts +491 -0
- package/src/monitor-access.ts +465 -0
- package/src/monitor-durable.test.ts +39 -0
- package/src/monitor-durable.ts +23 -0
- package/src/monitor-reply-delivery.ts +156 -0
- package/src/monitor-routing.ts +65 -0
- package/src/monitor-types.ts +33 -0
- package/src/monitor-webhook.test.ts +587 -0
- package/src/monitor-webhook.ts +303 -0
- package/src/monitor.reply-delivery.test.ts +144 -0
- package/src/monitor.test.ts +159 -0
- package/src/monitor.ts +527 -0
- package/src/monitor.webhook-routing.test.ts +257 -0
- package/src/runtime.ts +9 -0
- package/src/secret-contract.test.ts +60 -0
- package/src/secret-contract.ts +161 -0
- package/src/setup-core.ts +40 -0
- package/src/setup-surface.ts +243 -0
- package/src/setup.test.ts +619 -0
- package/src/targets.test.ts +453 -0
- package/src/targets.ts +66 -0
- package/src/types.config.ts +3 -0
- package/src/types.ts +73 -0
- package/test-api.ts +2 -0
- package/tsconfig.json +16 -0
- package/api.js +0 -7
- package/channel-config-api.js +0 -7
- package/channel-plugin-api.js +0 -7
- package/contract-api.js +0 -7
- package/doctor-contract-api.js +0 -7
- package/index.js +0 -7
- package/runtime-api.js +0 -7
- package/secret-contract-api.js +0 -7
- package/setup-entry.js +0 -7
- package/setup-plugin-api.js +0 -7
- package/test-api.js +0 -7
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
|
|
3
|
+
import {
|
|
4
|
+
normalizeWebhookPath,
|
|
5
|
+
resolveRequestClientIp,
|
|
6
|
+
type FixedWindowRateLimiter,
|
|
7
|
+
} from "klaw/plugin-sdk/webhook-ingress";
|
|
8
|
+
import type { WebhookInFlightLimiter } from "klaw/plugin-sdk/webhook-request-guards";
|
|
9
|
+
import { readJsonWebhookBodyOrReject } from "klaw/plugin-sdk/webhook-request-guards";
|
|
10
|
+
import {
|
|
11
|
+
resolveWebhookTargetWithAuthOrReject,
|
|
12
|
+
withResolvedWebhookRequestPipeline,
|
|
13
|
+
} from "klaw/plugin-sdk/webhook-targets";
|
|
14
|
+
import { verifyGoogleChatRequest } from "./auth.js";
|
|
15
|
+
import type { WebhookTarget } from "./monitor-types.js";
|
|
16
|
+
import type {
|
|
17
|
+
GoogleChatEvent,
|
|
18
|
+
GoogleChatMessage,
|
|
19
|
+
GoogleChatSpace,
|
|
20
|
+
GoogleChatUser,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
function extractBearerToken(header: unknown): string {
|
|
24
|
+
const authHeader = Array.isArray(header)
|
|
25
|
+
? typeof header[0] === "string"
|
|
26
|
+
? header[0]
|
|
27
|
+
: ""
|
|
28
|
+
: typeof header === "string"
|
|
29
|
+
? header
|
|
30
|
+
: "";
|
|
31
|
+
return normalizeLowercaseStringOrEmpty(authHeader).startsWith("bearer ")
|
|
32
|
+
? authHeader.slice("bearer ".length).trim()
|
|
33
|
+
: "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ADD_ON_PREAUTH_MAX_BYTES = 16 * 1024;
|
|
37
|
+
const ADD_ON_PREAUTH_TIMEOUT_MS = 3_000;
|
|
38
|
+
|
|
39
|
+
type ParsedGoogleChatInboundPayload =
|
|
40
|
+
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
|
|
41
|
+
| { ok: false };
|
|
42
|
+
type ParsedGoogleChatInboundSuccess = Extract<ParsedGoogleChatInboundPayload, { ok: true }>;
|
|
43
|
+
|
|
44
|
+
function parseGoogleChatInboundPayload(
|
|
45
|
+
raw: unknown,
|
|
46
|
+
res: ServerResponse,
|
|
47
|
+
): ParsedGoogleChatInboundPayload {
|
|
48
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
49
|
+
res.statusCode = 400;
|
|
50
|
+
res.end("invalid payload");
|
|
51
|
+
return { ok: false };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let eventPayload = raw;
|
|
55
|
+
let addOnBearerToken = "";
|
|
56
|
+
|
|
57
|
+
// Transform Google Workspace Add-on format to standard Chat API format.
|
|
58
|
+
const rawObj = raw as {
|
|
59
|
+
commonEventObject?: { hostApp?: string };
|
|
60
|
+
chat?: {
|
|
61
|
+
messagePayload?: { space?: GoogleChatSpace; message?: GoogleChatMessage };
|
|
62
|
+
user?: GoogleChatUser;
|
|
63
|
+
eventTime?: string;
|
|
64
|
+
};
|
|
65
|
+
authorizationEventObject?: { systemIdToken?: string };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
|
|
69
|
+
const chat = rawObj.chat;
|
|
70
|
+
const messagePayload = chat.messagePayload;
|
|
71
|
+
eventPayload = {
|
|
72
|
+
type: "MESSAGE",
|
|
73
|
+
space: messagePayload?.space,
|
|
74
|
+
message: messagePayload?.message,
|
|
75
|
+
user: chat.user,
|
|
76
|
+
eventTime: chat.eventTime,
|
|
77
|
+
};
|
|
78
|
+
addOnBearerToken =
|
|
79
|
+
typeof rawObj.authorizationEventObject?.systemIdToken === "string"
|
|
80
|
+
? rawObj.authorizationEventObject.systemIdToken.trim()
|
|
81
|
+
: "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const event = eventPayload as GoogleChatEvent;
|
|
85
|
+
const eventType = event.type ?? (eventPayload as { eventType?: string }).eventType;
|
|
86
|
+
if (typeof eventType !== "string") {
|
|
87
|
+
res.statusCode = 400;
|
|
88
|
+
res.end("invalid payload");
|
|
89
|
+
return { ok: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
|
|
93
|
+
res.statusCode = 400;
|
|
94
|
+
res.end("invalid payload");
|
|
95
|
+
return { ok: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (eventType === "MESSAGE") {
|
|
99
|
+
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
|
|
100
|
+
res.statusCode = 400;
|
|
101
|
+
res.end("invalid payload");
|
|
102
|
+
return { ok: false };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { ok: true, event, addOnBearerToken };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type GoogleChatWebhookAuthRejection = {
|
|
110
|
+
target: WebhookTarget;
|
|
111
|
+
reason: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
async function verifyGoogleChatTargetAuth(
|
|
115
|
+
target: WebhookTarget,
|
|
116
|
+
bearer: string,
|
|
117
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
118
|
+
const verification = await verifyGoogleChatRequest({
|
|
119
|
+
bearer,
|
|
120
|
+
audienceType: target.audienceType,
|
|
121
|
+
audience: target.audience,
|
|
122
|
+
expectedAddOnPrincipal: target.account.config.appPrincipal,
|
|
123
|
+
});
|
|
124
|
+
return verification.ok ? { ok: true } : { ok: false, reason: verification.reason ?? "unknown" };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function logGoogleChatWebhookAuthRejections(rejections: GoogleChatWebhookAuthRejection[]): void {
|
|
128
|
+
for (const rejection of rejections) {
|
|
129
|
+
rejection.target.runtime.log?.(
|
|
130
|
+
`[${rejection.target.account.accountId}] Google Chat webhook auth rejected: ${rejection.reason}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function logGoogleChatWebhookAuthRejectedForTargets(
|
|
136
|
+
targets: readonly WebhookTarget[],
|
|
137
|
+
reason: string,
|
|
138
|
+
): void {
|
|
139
|
+
logGoogleChatWebhookAuthRejections(targets.map((target) => ({ target, reason })));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function resolveGoogleChatWebhookTargetWithAuthOrReject(params: {
|
|
143
|
+
targets: readonly WebhookTarget[];
|
|
144
|
+
res: ServerResponse;
|
|
145
|
+
bearer: string;
|
|
146
|
+
}): Promise<WebhookTarget | null> {
|
|
147
|
+
const rejections: GoogleChatWebhookAuthRejection[] = [];
|
|
148
|
+
let verifiedTargetCount = 0;
|
|
149
|
+
const selectedTarget = await resolveWebhookTargetWithAuthOrReject({
|
|
150
|
+
targets: params.targets,
|
|
151
|
+
res: params.res,
|
|
152
|
+
isMatch: async (target) => {
|
|
153
|
+
const verification = await verifyGoogleChatTargetAuth(target, params.bearer);
|
|
154
|
+
if (verification.ok) {
|
|
155
|
+
verifiedTargetCount += 1;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
rejections.push({ target, reason: verification.reason });
|
|
159
|
+
return false;
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
if (!selectedTarget && verifiedTargetCount === 0) {
|
|
163
|
+
logGoogleChatWebhookAuthRejections(rejections);
|
|
164
|
+
}
|
|
165
|
+
return selectedTarget;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function warnAppPrincipalMisconfiguration(params: {
|
|
169
|
+
accountId: string;
|
|
170
|
+
audienceType?: string;
|
|
171
|
+
appPrincipal?: string | null;
|
|
172
|
+
log?: (message: string) => void;
|
|
173
|
+
}): void {
|
|
174
|
+
if (params.audienceType !== "app-url") {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const principal = params.appPrincipal?.trim();
|
|
178
|
+
if (!principal) {
|
|
179
|
+
params.log?.(
|
|
180
|
+
`[${params.accountId}] appPrincipal is missing for audienceType "app-url"; add-on token verification will fail. Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.`,
|
|
181
|
+
);
|
|
182
|
+
} else if (principal.includes("@")) {
|
|
183
|
+
params.log?.(
|
|
184
|
+
`[${params.accountId}] appPrincipal "${principal}" looks like an email address. Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createGoogleChatWebhookRequestHandler(params: {
|
|
190
|
+
webhookTargets: Map<string, WebhookTarget[]>;
|
|
191
|
+
webhookRateLimiter: FixedWindowRateLimiter;
|
|
192
|
+
webhookInFlightLimiter: WebhookInFlightLimiter;
|
|
193
|
+
processEvent: (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
|
194
|
+
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
|
|
195
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
|
|
196
|
+
const path = normalizeWebhookPath(new URL(req.url ?? "/", "http://localhost").pathname);
|
|
197
|
+
// Shared-path registrations use the same gateway proxy settings in normal runtime setup.
|
|
198
|
+
const config = params.webhookTargets.get(path)?.[0]?.config;
|
|
199
|
+
const clientIp =
|
|
200
|
+
resolveRequestClientIp(
|
|
201
|
+
req,
|
|
202
|
+
config?.gateway?.trustedProxies,
|
|
203
|
+
config?.gateway?.allowRealIpFallback === true,
|
|
204
|
+
) ?? "unknown";
|
|
205
|
+
|
|
206
|
+
return await withResolvedWebhookRequestPipeline({
|
|
207
|
+
req,
|
|
208
|
+
res,
|
|
209
|
+
targetsByPath: params.webhookTargets,
|
|
210
|
+
allowMethods: ["POST"],
|
|
211
|
+
requireJsonContentType: true,
|
|
212
|
+
rateLimiter: params.webhookRateLimiter,
|
|
213
|
+
rateLimitKey: `${path}:${clientIp}`,
|
|
214
|
+
inFlightLimiter: params.webhookInFlightLimiter,
|
|
215
|
+
handle: async ({ targets }) => {
|
|
216
|
+
const headerBearer = extractBearerToken(req.headers.authorization);
|
|
217
|
+
let selectedTarget: WebhookTarget | null = null;
|
|
218
|
+
let parsedEvent: GoogleChatEvent | null = null;
|
|
219
|
+
const readAndParseEvent = async (
|
|
220
|
+
profile: "pre-auth" | "post-auth",
|
|
221
|
+
): Promise<ParsedGoogleChatInboundSuccess | null> => {
|
|
222
|
+
const body = await readJsonWebhookBodyOrReject({
|
|
223
|
+
req,
|
|
224
|
+
res,
|
|
225
|
+
profile,
|
|
226
|
+
...(profile === "pre-auth"
|
|
227
|
+
? {
|
|
228
|
+
maxBytes: ADD_ON_PREAUTH_MAX_BYTES,
|
|
229
|
+
timeoutMs: ADD_ON_PREAUTH_TIMEOUT_MS,
|
|
230
|
+
}
|
|
231
|
+
: {}),
|
|
232
|
+
emptyObjectOnEmpty: false,
|
|
233
|
+
invalidJsonMessage: "invalid payload",
|
|
234
|
+
});
|
|
235
|
+
if (!body.ok) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const parsed = parseGoogleChatInboundPayload(body.value, res);
|
|
240
|
+
return parsed.ok ? parsed : null;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
if (headerBearer) {
|
|
244
|
+
selectedTarget = await resolveGoogleChatWebhookTargetWithAuthOrReject({
|
|
245
|
+
targets,
|
|
246
|
+
res,
|
|
247
|
+
bearer: headerBearer,
|
|
248
|
+
});
|
|
249
|
+
if (!selectedTarget) {
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const parsed = await readAndParseEvent("post-auth");
|
|
254
|
+
if (!parsed) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
parsedEvent = parsed.event;
|
|
258
|
+
} else {
|
|
259
|
+
const parsed = await readAndParseEvent("pre-auth");
|
|
260
|
+
if (!parsed) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
parsedEvent = parsed.event;
|
|
264
|
+
|
|
265
|
+
if (!parsed.addOnBearerToken) {
|
|
266
|
+
logGoogleChatWebhookAuthRejectedForTargets(targets, "missing token");
|
|
267
|
+
res.statusCode = 401;
|
|
268
|
+
res.end("unauthorized");
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
selectedTarget = await resolveGoogleChatWebhookTargetWithAuthOrReject({
|
|
273
|
+
targets,
|
|
274
|
+
res,
|
|
275
|
+
bearer: parsed.addOnBearerToken,
|
|
276
|
+
});
|
|
277
|
+
if (!selectedTarget) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!selectedTarget || !parsedEvent) {
|
|
283
|
+
res.statusCode = 401;
|
|
284
|
+
res.end("unauthorized");
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const dispatchTarget = selectedTarget;
|
|
289
|
+
dispatchTarget.statusSink?.({ lastInboundAt: Date.now() });
|
|
290
|
+
params.processEvent(parsedEvent, dispatchTarget).catch((err) => {
|
|
291
|
+
dispatchTarget.runtime.error?.(
|
|
292
|
+
`[${dispatchTarget.account.accountId}] Google Chat webhook failed: ${String(err)}`,
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
res.statusCode = 200;
|
|
297
|
+
res.setHeader("Content-Type", "application/json");
|
|
298
|
+
res.end("{}");
|
|
299
|
+
return true;
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { KlawConfig } from "../runtime-api.js";
|
|
3
|
+
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
|
4
|
+
import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
deleteGoogleChatMessage: vi.fn(),
|
|
8
|
+
sendGoogleChatMessage: vi.fn(),
|
|
9
|
+
updateGoogleChatMessage: vi.fn(),
|
|
10
|
+
uploadGoogleChatAttachment: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("./api.js", () => ({
|
|
14
|
+
deleteGoogleChatMessage: mocks.deleteGoogleChatMessage,
|
|
15
|
+
sendGoogleChatMessage: mocks.sendGoogleChatMessage,
|
|
16
|
+
updateGoogleChatMessage: mocks.updateGoogleChatMessage,
|
|
17
|
+
uploadGoogleChatAttachment: mocks.uploadGoogleChatAttachment,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const account = {
|
|
21
|
+
accountId: "default",
|
|
22
|
+
enabled: true,
|
|
23
|
+
credentialSource: "inline",
|
|
24
|
+
config: {},
|
|
25
|
+
} as ResolvedGoogleChatAccount;
|
|
26
|
+
|
|
27
|
+
const config = {} as KlawConfig;
|
|
28
|
+
|
|
29
|
+
function createCore(params?: {
|
|
30
|
+
chunks?: readonly string[];
|
|
31
|
+
media?: { buffer: Buffer; contentType?: string; fileName?: string };
|
|
32
|
+
}) {
|
|
33
|
+
return {
|
|
34
|
+
channel: {
|
|
35
|
+
text: {
|
|
36
|
+
resolveChunkMode: vi.fn(() => "markdown"),
|
|
37
|
+
chunkMarkdownTextWithMode: vi.fn((text: string) => params?.chunks ?? [text]),
|
|
38
|
+
},
|
|
39
|
+
media: {
|
|
40
|
+
readRemoteMediaBuffer: vi.fn(async () => params?.media ?? { buffer: Buffer.from("image") }),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
} as unknown as GoogleChatCoreRuntime;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createRuntime() {
|
|
47
|
+
return {
|
|
48
|
+
error: vi.fn(),
|
|
49
|
+
log: vi.fn(),
|
|
50
|
+
} satisfies GoogleChatRuntimeEnv;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let deliverGoogleChatReply: typeof import("./monitor-reply-delivery.js").deliverGoogleChatReply;
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
({ deliverGoogleChatReply } = await import("./monitor-reply-delivery.js"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterAll(() => {
|
|
61
|
+
vi.doUnmock("./api.js");
|
|
62
|
+
vi.resetModules();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("Google Chat reply delivery", () => {
|
|
66
|
+
it("resends the first text chunk as a new message when typing update fails", async () => {
|
|
67
|
+
const core = createCore({ chunks: ["first chunk", "second chunk"] });
|
|
68
|
+
const runtime = createRuntime();
|
|
69
|
+
const statusSink = vi.fn();
|
|
70
|
+
mocks.updateGoogleChatMessage.mockRejectedValueOnce(new Error("message not found"));
|
|
71
|
+
mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/fallback" });
|
|
72
|
+
|
|
73
|
+
await deliverGoogleChatReply({
|
|
74
|
+
payload: { text: "first chunk\n\nsecond chunk", replyToId: "spaces/AAA/threads/root" },
|
|
75
|
+
account,
|
|
76
|
+
spaceId: "spaces/AAA",
|
|
77
|
+
runtime,
|
|
78
|
+
core,
|
|
79
|
+
config,
|
|
80
|
+
statusSink,
|
|
81
|
+
typingMessageName: "spaces/AAA/messages/typing",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(mocks.updateGoogleChatMessage).toHaveBeenCalledWith({
|
|
85
|
+
account,
|
|
86
|
+
messageName: "spaces/AAA/messages/typing",
|
|
87
|
+
text: "first chunk",
|
|
88
|
+
});
|
|
89
|
+
expect(mocks.sendGoogleChatMessage).toHaveBeenCalledTimes(2);
|
|
90
|
+
expect(mocks.sendGoogleChatMessage).toHaveBeenNthCalledWith(1, {
|
|
91
|
+
account,
|
|
92
|
+
space: "spaces/AAA",
|
|
93
|
+
text: "first chunk",
|
|
94
|
+
thread: "spaces/AAA/threads/root",
|
|
95
|
+
});
|
|
96
|
+
expect(mocks.sendGoogleChatMessage).toHaveBeenNthCalledWith(2, {
|
|
97
|
+
account,
|
|
98
|
+
space: "spaces/AAA",
|
|
99
|
+
text: "second chunk",
|
|
100
|
+
thread: "spaces/AAA/threads/root",
|
|
101
|
+
});
|
|
102
|
+
expect(statusSink).toHaveBeenCalledTimes(2);
|
|
103
|
+
expect(runtime.error).toHaveBeenCalledWith(
|
|
104
|
+
"Google Chat message send failed: Error: message not found",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does not update a deleted typing message before sending media with a caption", async () => {
|
|
109
|
+
const core = createCore({
|
|
110
|
+
media: { buffer: Buffer.from("image"), contentType: "image/png", fileName: "reply.png" },
|
|
111
|
+
});
|
|
112
|
+
const runtime = createRuntime();
|
|
113
|
+
mocks.deleteGoogleChatMessage.mockResolvedValue(undefined);
|
|
114
|
+
mocks.uploadGoogleChatAttachment.mockResolvedValue({ attachmentUploadToken: "upload-token" });
|
|
115
|
+
mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/media" });
|
|
116
|
+
|
|
117
|
+
await deliverGoogleChatReply({
|
|
118
|
+
payload: {
|
|
119
|
+
text: "caption",
|
|
120
|
+
mediaUrl: "https://example.invalid/reply.png",
|
|
121
|
+
replyToId: "spaces/AAA/threads/root",
|
|
122
|
+
},
|
|
123
|
+
account,
|
|
124
|
+
spaceId: "spaces/AAA",
|
|
125
|
+
runtime,
|
|
126
|
+
core,
|
|
127
|
+
config,
|
|
128
|
+
typingMessageName: "spaces/AAA/messages/typing",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(mocks.deleteGoogleChatMessage).toHaveBeenCalledWith({
|
|
132
|
+
account,
|
|
133
|
+
messageName: "spaces/AAA/messages/typing",
|
|
134
|
+
});
|
|
135
|
+
expect(mocks.updateGoogleChatMessage).not.toHaveBeenCalled();
|
|
136
|
+
expect(mocks.sendGoogleChatMessage).toHaveBeenCalledWith({
|
|
137
|
+
account,
|
|
138
|
+
space: "spaces/AAA",
|
|
139
|
+
text: "caption",
|
|
140
|
+
thread: "spaces/AAA/threads/root",
|
|
141
|
+
attachments: [{ attachmentUploadToken: "upload-token", contentName: "reply.png" }],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { recordChannelBotPairLoopAndCheckSuppression } from "klaw/plugin-sdk/inbound-reply-dispatch";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
|
4
|
+
import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
|
|
5
|
+
import { testing } from "./monitor.js";
|
|
6
|
+
import type { GoogleChatEvent } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const apiMocks = vi.hoisted(() => ({
|
|
9
|
+
downloadGoogleChatMedia: vi.fn(),
|
|
10
|
+
sendGoogleChatMessage: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const accessMocks = vi.hoisted(() => ({
|
|
14
|
+
applyGoogleChatInboundAccessPolicy: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("./api.js", () => ({
|
|
18
|
+
downloadGoogleChatMedia: apiMocks.downloadGoogleChatMedia,
|
|
19
|
+
sendGoogleChatMessage: apiMocks.sendGoogleChatMessage,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("./monitor-access.js", () => ({
|
|
23
|
+
applyGoogleChatInboundAccessPolicy: accessMocks.applyGoogleChatInboundAccessPolicy,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
apiMocks.downloadGoogleChatMedia.mockReset();
|
|
28
|
+
apiMocks.sendGoogleChatMessage.mockReset();
|
|
29
|
+
accessMocks.applyGoogleChatInboundAccessPolicy.mockReset();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("googlechat monitor bot loop protection", () => {
|
|
33
|
+
it("maps accepted bot-authored messages to shared channel-turn facts", () => {
|
|
34
|
+
expect(
|
|
35
|
+
testing.resolveGoogleChatBotLoopProtection({
|
|
36
|
+
allowBots: true,
|
|
37
|
+
isBotSender: true,
|
|
38
|
+
senderId: "users/other-bot",
|
|
39
|
+
appUserId: "users/app-bot",
|
|
40
|
+
accountId: "work",
|
|
41
|
+
conversationId: "spaces/AAA",
|
|
42
|
+
config: { maxEventsPerWindow: 3 },
|
|
43
|
+
defaultsConfig: { maxEventsPerWindow: 20 },
|
|
44
|
+
eventTime: "2026-03-22T00:00:00.000Z",
|
|
45
|
+
}),
|
|
46
|
+
).toEqual({
|
|
47
|
+
scopeId: "work",
|
|
48
|
+
conversationId: "spaces/AAA",
|
|
49
|
+
senderId: "users/other-bot",
|
|
50
|
+
receiverId: "users/app-bot",
|
|
51
|
+
config: { maxEventsPerWindow: 3 },
|
|
52
|
+
defaultsConfig: { maxEventsPerWindow: 20 },
|
|
53
|
+
defaultEnabled: true,
|
|
54
|
+
nowMs: Date.parse("2026-03-22T00:00:00.000Z"),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not guard human messages or the app's own echo", () => {
|
|
59
|
+
expect(
|
|
60
|
+
testing.resolveGoogleChatBotLoopProtection({
|
|
61
|
+
allowBots: true,
|
|
62
|
+
isBotSender: false,
|
|
63
|
+
senderId: "users/alice",
|
|
64
|
+
appUserId: "users/app",
|
|
65
|
+
accountId: "work",
|
|
66
|
+
conversationId: "spaces/AAA",
|
|
67
|
+
}),
|
|
68
|
+
).toBeUndefined();
|
|
69
|
+
expect(
|
|
70
|
+
testing.resolveGoogleChatBotLoopProtection({
|
|
71
|
+
allowBots: true,
|
|
72
|
+
isBotSender: true,
|
|
73
|
+
senderId: "users/app",
|
|
74
|
+
appUserId: "users/app",
|
|
75
|
+
accountId: "work",
|
|
76
|
+
conversationId: "spaces/AAA",
|
|
77
|
+
}),
|
|
78
|
+
).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("layers space bot loop overrides over account settings field-by-field", () => {
|
|
82
|
+
expect(
|
|
83
|
+
testing.resolveGoogleChatBotLoopProtectionConfig({
|
|
84
|
+
accountConfig: { windowSeconds: 120, cooldownSeconds: 240 },
|
|
85
|
+
groupConfig: { maxEventsPerWindow: 3 },
|
|
86
|
+
}),
|
|
87
|
+
).toEqual({
|
|
88
|
+
maxEventsPerWindow: 3,
|
|
89
|
+
windowSeconds: 120,
|
|
90
|
+
cooldownSeconds: 240,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("suppresses bot loops before creating typing messages", async () => {
|
|
95
|
+
const eventTimeMs = Date.parse("2026-03-22T00:00:00.000Z");
|
|
96
|
+
const accountId = `bot-loop-typing-${eventTimeMs}`;
|
|
97
|
+
const conversationId = "spaces/LOOP";
|
|
98
|
+
const senderId = "users/other-bot";
|
|
99
|
+
const receiverId = "users/app";
|
|
100
|
+
const runTurn = vi.fn();
|
|
101
|
+
const core = {
|
|
102
|
+
logging: { shouldLogVerbose: () => false },
|
|
103
|
+
channel: {
|
|
104
|
+
turn: { run: runTurn },
|
|
105
|
+
},
|
|
106
|
+
} as unknown as GoogleChatCoreRuntime;
|
|
107
|
+
const runtime = { error: vi.fn(), log: vi.fn() } satisfies GoogleChatRuntimeEnv;
|
|
108
|
+
const account = {
|
|
109
|
+
accountId,
|
|
110
|
+
config: {
|
|
111
|
+
allowBots: true,
|
|
112
|
+
botUser: receiverId,
|
|
113
|
+
botLoopProtection: { maxEventsPerWindow: 1, windowSeconds: 60, cooldownSeconds: 60 },
|
|
114
|
+
typingIndicator: "message",
|
|
115
|
+
},
|
|
116
|
+
credentialSource: "inline",
|
|
117
|
+
} as ResolvedGoogleChatAccount;
|
|
118
|
+
const event = {
|
|
119
|
+
type: "MESSAGE",
|
|
120
|
+
eventTime: "2026-03-22T00:00:00.001Z",
|
|
121
|
+
space: { name: conversationId, type: "DM" },
|
|
122
|
+
message: {
|
|
123
|
+
name: "spaces/LOOP/messages/2",
|
|
124
|
+
text: "loop",
|
|
125
|
+
sender: { name: senderId, type: "BOT" },
|
|
126
|
+
},
|
|
127
|
+
} satisfies GoogleChatEvent;
|
|
128
|
+
|
|
129
|
+
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
|
|
130
|
+
ok: true,
|
|
131
|
+
commandAuthorized: undefined,
|
|
132
|
+
effectiveWasMentioned: undefined,
|
|
133
|
+
groupBotLoopProtection: undefined,
|
|
134
|
+
groupSystemPrompt: undefined,
|
|
135
|
+
});
|
|
136
|
+
recordChannelBotPairLoopAndCheckSuppression({
|
|
137
|
+
scopeId: accountId,
|
|
138
|
+
conversationId,
|
|
139
|
+
senderId,
|
|
140
|
+
receiverId,
|
|
141
|
+
config: account.config.botLoopProtection,
|
|
142
|
+
defaultEnabled: true,
|
|
143
|
+
nowMs: eventTimeMs,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await testing.processMessageWithPipeline({
|
|
147
|
+
event,
|
|
148
|
+
account,
|
|
149
|
+
config: {},
|
|
150
|
+
runtime,
|
|
151
|
+
core,
|
|
152
|
+
mediaMaxMb: 10,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(apiMocks.sendGoogleChatMessage).not.toHaveBeenCalled();
|
|
156
|
+
expect(apiMocks.downloadGoogleChatMedia).not.toHaveBeenCalled();
|
|
157
|
+
expect(runTurn).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
});
|