@chat-adapter/gchat 4.0.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/dist/index.js ADDED
@@ -0,0 +1,1642 @@
1
+ // src/index.ts
2
+ import {
3
+ convertEmojiPlaceholders as convertEmojiPlaceholders2,
4
+ defaultEmojiResolver,
5
+ isCardElement,
6
+ RateLimitError
7
+ } from "chat";
8
+ import { google as google2 } from "googleapis";
9
+
10
+ // src/cards.ts
11
+ import {
12
+ convertEmojiPlaceholders
13
+ } from "chat";
14
+ function convertEmoji(text) {
15
+ return convertEmojiPlaceholders(text, "gchat");
16
+ }
17
+ function cardToGoogleCard(card, cardId) {
18
+ const sections = [];
19
+ let header;
20
+ if (card.title || card.subtitle || card.imageUrl) {
21
+ header = {
22
+ title: convertEmoji(card.title || "")
23
+ };
24
+ if (card.subtitle) {
25
+ header.subtitle = convertEmoji(card.subtitle);
26
+ }
27
+ if (card.imageUrl) {
28
+ header.imageUrl = card.imageUrl;
29
+ header.imageType = "SQUARE";
30
+ }
31
+ }
32
+ let currentWidgets = [];
33
+ for (const child of card.children) {
34
+ if (child.type === "section") {
35
+ if (currentWidgets.length > 0) {
36
+ sections.push({ widgets: currentWidgets });
37
+ currentWidgets = [];
38
+ }
39
+ const sectionWidgets = convertSectionToWidgets(child);
40
+ sections.push({ widgets: sectionWidgets });
41
+ } else {
42
+ const widgets = convertChildToWidgets(child);
43
+ currentWidgets.push(...widgets);
44
+ }
45
+ }
46
+ if (currentWidgets.length > 0) {
47
+ sections.push({ widgets: currentWidgets });
48
+ }
49
+ if (sections.length === 0) {
50
+ sections.push({
51
+ widgets: [{ textParagraph: { text: "" } }]
52
+ });
53
+ }
54
+ const googleCard = {
55
+ card: {
56
+ sections
57
+ }
58
+ };
59
+ if (header) {
60
+ googleCard.card.header = header;
61
+ }
62
+ if (cardId) {
63
+ googleCard.cardId = cardId;
64
+ }
65
+ return googleCard;
66
+ }
67
+ function convertChildToWidgets(child) {
68
+ switch (child.type) {
69
+ case "text":
70
+ return [convertTextToWidget(child)];
71
+ case "image":
72
+ return [convertImageToWidget(child)];
73
+ case "divider":
74
+ return [convertDividerToWidget(child)];
75
+ case "actions":
76
+ return [convertActionsToWidget(child)];
77
+ case "section":
78
+ return convertSectionToWidgets(child);
79
+ case "fields":
80
+ return convertFieldsToWidgets(child);
81
+ default:
82
+ return [];
83
+ }
84
+ }
85
+ function convertTextToWidget(element) {
86
+ let text = convertEmoji(element.content);
87
+ if (element.style === "bold") {
88
+ text = `*${text}*`;
89
+ } else if (element.style === "muted") {
90
+ text = convertEmoji(element.content);
91
+ }
92
+ return {
93
+ textParagraph: { text }
94
+ };
95
+ }
96
+ function convertImageToWidget(element) {
97
+ return {
98
+ image: {
99
+ imageUrl: element.url,
100
+ altText: element.alt || "Image"
101
+ }
102
+ };
103
+ }
104
+ function convertDividerToWidget(_element) {
105
+ return { divider: {} };
106
+ }
107
+ function convertActionsToWidget(element) {
108
+ const buttons = element.children.map(
109
+ (button) => convertButtonToGoogleButton(button)
110
+ );
111
+ return {
112
+ buttonList: { buttons }
113
+ };
114
+ }
115
+ function convertButtonToGoogleButton(button) {
116
+ const googleButton = {
117
+ text: convertEmoji(button.label),
118
+ onClick: {
119
+ action: {
120
+ function: button.id,
121
+ parameters: button.value ? [{ key: "value", value: button.value }] : []
122
+ }
123
+ }
124
+ };
125
+ if (button.style === "primary") {
126
+ googleButton.color = { red: 0.2, green: 0.5, blue: 0.9 };
127
+ } else if (button.style === "danger") {
128
+ googleButton.color = { red: 0.9, green: 0.2, blue: 0.2 };
129
+ }
130
+ return googleButton;
131
+ }
132
+ function convertSectionToWidgets(element) {
133
+ const widgets = [];
134
+ for (const child of element.children) {
135
+ widgets.push(...convertChildToWidgets(child));
136
+ }
137
+ return widgets;
138
+ }
139
+ function convertFieldsToWidgets(element) {
140
+ return element.children.map((field) => ({
141
+ decoratedText: {
142
+ topLabel: convertEmoji(field.label),
143
+ text: convertEmoji(field.value)
144
+ }
145
+ }));
146
+ }
147
+ function cardToFallbackText(card) {
148
+ const parts = [];
149
+ if (card.title) {
150
+ parts.push(`*${convertEmoji(card.title)}*`);
151
+ }
152
+ if (card.subtitle) {
153
+ parts.push(convertEmoji(card.subtitle));
154
+ }
155
+ for (const child of card.children) {
156
+ const text = childToFallbackText(child);
157
+ if (text) {
158
+ parts.push(text);
159
+ }
160
+ }
161
+ return parts.join("\n");
162
+ }
163
+ function childToFallbackText(child) {
164
+ switch (child.type) {
165
+ case "text":
166
+ return convertEmoji(child.content);
167
+ case "fields":
168
+ return child.children.map((f) => `*${convertEmoji(f.label)}*: ${convertEmoji(f.value)}`).join("\n");
169
+ case "actions":
170
+ return `[${child.children.map((b) => convertEmoji(b.label)).join("] [")}]`;
171
+ case "section":
172
+ return child.children.map((c) => childToFallbackText(c)).filter(Boolean).join("\n");
173
+ default:
174
+ return null;
175
+ }
176
+ }
177
+
178
+ // src/markdown.ts
179
+ import {
180
+ BaseFormatConverter,
181
+ parseMarkdown
182
+ } from "chat";
183
+ var GoogleChatFormatConverter = class extends BaseFormatConverter {
184
+ /**
185
+ * Render an AST to Google Chat format.
186
+ */
187
+ fromAst(ast) {
188
+ const parts = [];
189
+ for (const node of ast.children) {
190
+ parts.push(this.nodeToGChat(node));
191
+ }
192
+ return parts.join("\n\n");
193
+ }
194
+ /**
195
+ * Parse Google Chat message into an AST.
196
+ */
197
+ toAst(gchatText) {
198
+ let markdown = gchatText;
199
+ markdown = markdown.replace(/(?<![_*\\])\*([^*\n]+)\*(?![_*])/g, "**$1**");
200
+ markdown = markdown.replace(/(?<!~)~([^~\n]+)~(?!~)/g, "~~$1~~");
201
+ return parseMarkdown(markdown);
202
+ }
203
+ nodeToGChat(node) {
204
+ switch (node.type) {
205
+ case "paragraph":
206
+ return node.children.map((child) => this.nodeToGChat(child)).join("");
207
+ case "text": {
208
+ return node.value;
209
+ }
210
+ case "strong":
211
+ return `*${node.children.map((child) => this.nodeToGChat(child)).join("")}*`;
212
+ case "emphasis":
213
+ return `_${node.children.map((child) => this.nodeToGChat(child)).join("")}_`;
214
+ case "delete":
215
+ return `~${node.children.map((child) => this.nodeToGChat(child)).join("")}~`;
216
+ case "inlineCode":
217
+ return `\`${node.value}\``;
218
+ case "code": {
219
+ const codeNode = node;
220
+ return `\`\`\`
221
+ ${codeNode.value}
222
+ \`\`\``;
223
+ }
224
+ case "link": {
225
+ const linkNode = node;
226
+ const linkText = linkNode.children.map((child) => this.nodeToGChat(child)).join("");
227
+ if (linkText === linkNode.url) {
228
+ return linkNode.url;
229
+ }
230
+ return `${linkText} (${linkNode.url})`;
231
+ }
232
+ case "blockquote":
233
+ return node.children.map((child) => `> ${this.nodeToGChat(child)}`).join("\n");
234
+ case "list":
235
+ return node.children.map((item, i) => {
236
+ const prefix = node.ordered ? `${i + 1}.` : "\u2022";
237
+ const content = item.children.map((child) => this.nodeToGChat(child)).join("");
238
+ return `${prefix} ${content}`;
239
+ }).join("\n");
240
+ case "listItem":
241
+ return node.children.map((child) => this.nodeToGChat(child)).join("");
242
+ case "break":
243
+ return "\n";
244
+ case "thematicBreak":
245
+ return "---";
246
+ default:
247
+ if ("children" in node && Array.isArray(node.children)) {
248
+ return node.children.map((child) => this.nodeToGChat(child)).join("");
249
+ }
250
+ if ("value" in node) {
251
+ return String(node.value);
252
+ }
253
+ return "";
254
+ }
255
+ }
256
+ };
257
+
258
+ // src/workspace-events.ts
259
+ import { google } from "googleapis";
260
+ async function createSpaceSubscription(options, auth) {
261
+ const { spaceName, pubsubTopic, ttlSeconds = 86400 } = options;
262
+ let authClient;
263
+ if ("credentials" in auth) {
264
+ authClient = new google.auth.JWT({
265
+ email: auth.credentials.client_email,
266
+ key: auth.credentials.private_key,
267
+ // For domain-wide delegation, impersonate a user
268
+ subject: auth.impersonateUser,
269
+ scopes: [
270
+ "https://www.googleapis.com/auth/chat.spaces.readonly",
271
+ "https://www.googleapis.com/auth/chat.messages.readonly"
272
+ ]
273
+ });
274
+ } else if ("useApplicationDefaultCredentials" in auth) {
275
+ authClient = new google.auth.GoogleAuth({
276
+ // Note: ADC with impersonation requires different setup
277
+ scopes: [
278
+ "https://www.googleapis.com/auth/chat.spaces.readonly",
279
+ "https://www.googleapis.com/auth/chat.messages.readonly"
280
+ ]
281
+ });
282
+ } else {
283
+ authClient = auth.auth;
284
+ }
285
+ const workspaceEvents = google.workspaceevents({
286
+ version: "v1",
287
+ auth: authClient
288
+ });
289
+ const response = await workspaceEvents.subscriptions.create({
290
+ requestBody: {
291
+ targetResource: `//chat.googleapis.com/${spaceName}`,
292
+ eventTypes: [
293
+ "google.workspace.chat.message.v1.created",
294
+ "google.workspace.chat.message.v1.updated",
295
+ "google.workspace.chat.reaction.v1.created",
296
+ "google.workspace.chat.reaction.v1.deleted"
297
+ ],
298
+ notificationEndpoint: {
299
+ pubsubTopic
300
+ },
301
+ payloadOptions: {
302
+ includeResource: true
303
+ },
304
+ ttl: `${ttlSeconds}s`
305
+ }
306
+ });
307
+ const operation = response.data;
308
+ if (operation.done && operation.response) {
309
+ const subscription = operation.response;
310
+ return {
311
+ name: subscription.name || "",
312
+ expireTime: subscription.expireTime || ""
313
+ };
314
+ }
315
+ return {
316
+ name: operation.name || "pending",
317
+ expireTime: ""
318
+ };
319
+ }
320
+ async function listSpaceSubscriptions(spaceName, auth) {
321
+ let authClient;
322
+ if ("credentials" in auth) {
323
+ authClient = new google.auth.JWT({
324
+ email: auth.credentials.client_email,
325
+ key: auth.credentials.private_key,
326
+ subject: auth.impersonateUser,
327
+ scopes: ["https://www.googleapis.com/auth/chat.spaces.readonly"]
328
+ });
329
+ } else if ("useApplicationDefaultCredentials" in auth) {
330
+ authClient = new google.auth.GoogleAuth({
331
+ scopes: ["https://www.googleapis.com/auth/chat.spaces.readonly"]
332
+ });
333
+ } else {
334
+ authClient = auth.auth;
335
+ }
336
+ const workspaceEvents = google.workspaceevents({
337
+ version: "v1",
338
+ auth: authClient
339
+ });
340
+ const response = await workspaceEvents.subscriptions.list({
341
+ filter: `target_resource="//chat.googleapis.com/${spaceName}"`
342
+ });
343
+ return (response.data.subscriptions || []).map((sub) => ({
344
+ name: sub.name || "",
345
+ expireTime: sub.expireTime || "",
346
+ eventTypes: sub.eventTypes || []
347
+ }));
348
+ }
349
+ async function deleteSpaceSubscription(subscriptionName, auth) {
350
+ let authClient;
351
+ if ("credentials" in auth) {
352
+ authClient = new google.auth.JWT({
353
+ email: auth.credentials.client_email,
354
+ key: auth.credentials.private_key,
355
+ subject: auth.impersonateUser,
356
+ scopes: ["https://www.googleapis.com/auth/chat.spaces.readonly"]
357
+ });
358
+ } else if ("useApplicationDefaultCredentials" in auth) {
359
+ authClient = new google.auth.GoogleAuth({
360
+ scopes: ["https://www.googleapis.com/auth/chat.spaces.readonly"]
361
+ });
362
+ } else {
363
+ authClient = auth.auth;
364
+ }
365
+ const workspaceEvents = google.workspaceevents({
366
+ version: "v1",
367
+ auth: authClient
368
+ });
369
+ await workspaceEvents.subscriptions.delete({
370
+ name: subscriptionName
371
+ });
372
+ }
373
+ function decodePubSubMessage(pushMessage) {
374
+ const data = Buffer.from(pushMessage.message.data, "base64").toString(
375
+ "utf-8"
376
+ );
377
+ const payload = JSON.parse(data);
378
+ const attributes = pushMessage.message.attributes || {};
379
+ return {
380
+ subscription: pushMessage.subscription,
381
+ targetResource: attributes["ce-subject"] || "",
382
+ eventType: attributes["ce-type"] || "",
383
+ eventTime: attributes["ce-time"] || pushMessage.message.publishTime,
384
+ message: payload.message,
385
+ reaction: payload.reaction
386
+ };
387
+ }
388
+ function verifyPubSubRequest(request, _expectedAudience) {
389
+ if (request.method !== "POST") {
390
+ return false;
391
+ }
392
+ const contentType = request.headers.get("content-type");
393
+ if (!contentType?.includes("application/json")) {
394
+ return false;
395
+ }
396
+ return true;
397
+ }
398
+
399
+ // src/index.ts
400
+ var SUBSCRIPTION_REFRESH_BUFFER_MS = 60 * 60 * 1e3;
401
+ var SUBSCRIPTION_CACHE_TTL_MS = 25 * 60 * 60 * 1e3;
402
+ var SPACE_SUB_KEY_PREFIX = "gchat:space-sub:";
403
+ var USER_INFO_KEY_PREFIX = "gchat:user:";
404
+ var USER_INFO_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
405
+ var GoogleChatAdapter = class {
406
+ name = "gchat";
407
+ userName;
408
+ /** Bot's user ID (e.g., "users/123...") - learned from annotations */
409
+ botUserId;
410
+ chatApi;
411
+ chat = null;
412
+ state = null;
413
+ logger = null;
414
+ formatConverter = new GoogleChatFormatConverter();
415
+ pubsubTopic;
416
+ credentials;
417
+ useADC = false;
418
+ /** Custom auth client (e.g., Vercel OIDC) */
419
+ customAuth;
420
+ /** Auth client for making authenticated requests */
421
+ authClient;
422
+ /** User email to impersonate for Workspace Events API (domain-wide delegation) */
423
+ impersonateUser;
424
+ /** In-progress subscription creations to prevent duplicate requests */
425
+ pendingSubscriptions = /* @__PURE__ */ new Map();
426
+ /** Chat API client with impersonation for user-context operations (DMs, etc.) */
427
+ impersonatedChatApi;
428
+ constructor(config) {
429
+ this.userName = config.userName || "bot";
430
+ this.pubsubTopic = config.pubsubTopic;
431
+ this.impersonateUser = config.impersonateUser;
432
+ let auth;
433
+ const scopes = [
434
+ "https://www.googleapis.com/auth/chat.bot",
435
+ "https://www.googleapis.com/auth/chat.messages.reactions.create",
436
+ "https://www.googleapis.com/auth/chat.messages.reactions",
437
+ "https://www.googleapis.com/auth/chat.spaces.create"
438
+ ];
439
+ if ("credentials" in config && config.credentials) {
440
+ this.credentials = config.credentials;
441
+ auth = new google2.auth.JWT({
442
+ email: config.credentials.client_email,
443
+ key: config.credentials.private_key,
444
+ scopes
445
+ });
446
+ } else if ("useApplicationDefaultCredentials" in config && config.useApplicationDefaultCredentials) {
447
+ this.useADC = true;
448
+ auth = new google2.auth.GoogleAuth({
449
+ scopes
450
+ });
451
+ } else if ("auth" in config && config.auth) {
452
+ this.customAuth = config.auth;
453
+ auth = config.auth;
454
+ } else {
455
+ throw new Error(
456
+ "GoogleChatAdapter requires one of: credentials, useApplicationDefaultCredentials, or auth"
457
+ );
458
+ }
459
+ this.authClient = auth;
460
+ this.chatApi = google2.chat({ version: "v1", auth });
461
+ if (this.impersonateUser) {
462
+ if (this.credentials) {
463
+ const impersonatedAuth = new google2.auth.JWT({
464
+ email: this.credentials.client_email,
465
+ key: this.credentials.private_key,
466
+ scopes: [
467
+ "https://www.googleapis.com/auth/chat.spaces",
468
+ "https://www.googleapis.com/auth/chat.spaces.create"
469
+ ],
470
+ subject: this.impersonateUser
471
+ });
472
+ this.impersonatedChatApi = google2.chat({
473
+ version: "v1",
474
+ auth: impersonatedAuth
475
+ });
476
+ } else if (this.useADC) {
477
+ const impersonatedAuth = new google2.auth.GoogleAuth({
478
+ scopes: [
479
+ "https://www.googleapis.com/auth/chat.spaces",
480
+ "https://www.googleapis.com/auth/chat.spaces.create"
481
+ ],
482
+ clientOptions: {
483
+ subject: this.impersonateUser
484
+ }
485
+ });
486
+ this.impersonatedChatApi = google2.chat({
487
+ version: "v1",
488
+ auth: impersonatedAuth
489
+ });
490
+ }
491
+ }
492
+ }
493
+ async initialize(chat) {
494
+ this.chat = chat;
495
+ this.state = chat.getState();
496
+ this.logger = chat.getLogger(this.name);
497
+ if (!this.botUserId) {
498
+ const savedBotUserId = await this.state.get("gchat:botUserId");
499
+ if (savedBotUserId) {
500
+ this.botUserId = savedBotUserId;
501
+ this.logger?.debug("Restored bot user ID from state", {
502
+ botUserId: this.botUserId
503
+ });
504
+ }
505
+ }
506
+ }
507
+ /**
508
+ * Called when a thread is subscribed to.
509
+ * Ensures the space has a Workspace Events subscription so we receive all messages.
510
+ */
511
+ async onThreadSubscribe(threadId) {
512
+ this.logger?.info("onThreadSubscribe called", {
513
+ threadId,
514
+ hasPubsubTopic: !!this.pubsubTopic,
515
+ pubsubTopic: this.pubsubTopic
516
+ });
517
+ if (!this.pubsubTopic) {
518
+ this.logger?.warn(
519
+ "No pubsubTopic configured, skipping space subscription. Set GOOGLE_CHAT_PUBSUB_TOPIC env var."
520
+ );
521
+ return;
522
+ }
523
+ const { spaceName } = this.decodeThreadId(threadId);
524
+ await this.ensureSpaceSubscription(spaceName);
525
+ }
526
+ /**
527
+ * Ensure a Workspace Events subscription exists for a space.
528
+ * Creates one if it doesn't exist or is about to expire.
529
+ */
530
+ async ensureSpaceSubscription(spaceName) {
531
+ this.logger?.info("ensureSpaceSubscription called", {
532
+ spaceName,
533
+ hasPubsubTopic: !!this.pubsubTopic,
534
+ hasState: !!this.state,
535
+ hasCredentials: !!this.credentials,
536
+ hasADC: this.useADC
537
+ });
538
+ if (!this.pubsubTopic || !this.state) {
539
+ this.logger?.warn("ensureSpaceSubscription skipped - missing config", {
540
+ hasPubsubTopic: !!this.pubsubTopic,
541
+ hasState: !!this.state
542
+ });
543
+ return;
544
+ }
545
+ const cacheKey = `${SPACE_SUB_KEY_PREFIX}${spaceName}`;
546
+ const cached = await this.state.get(cacheKey);
547
+ if (cached) {
548
+ const timeUntilExpiry = cached.expireTime - Date.now();
549
+ if (timeUntilExpiry > SUBSCRIPTION_REFRESH_BUFFER_MS) {
550
+ this.logger?.debug("Space subscription still valid", {
551
+ spaceName,
552
+ expiresIn: Math.round(timeUntilExpiry / 1e3 / 60)
553
+ });
554
+ return;
555
+ }
556
+ this.logger?.debug("Space subscription expiring soon, will refresh", {
557
+ spaceName,
558
+ expiresIn: Math.round(timeUntilExpiry / 1e3 / 60)
559
+ });
560
+ }
561
+ const pending = this.pendingSubscriptions.get(spaceName);
562
+ if (pending) {
563
+ this.logger?.debug("Subscription creation already in progress", {
564
+ spaceName
565
+ });
566
+ return pending;
567
+ }
568
+ const createPromise = this.createSpaceSubscriptionWithCache(
569
+ spaceName,
570
+ cacheKey
571
+ );
572
+ this.pendingSubscriptions.set(spaceName, createPromise);
573
+ try {
574
+ await createPromise;
575
+ } finally {
576
+ this.pendingSubscriptions.delete(spaceName);
577
+ }
578
+ }
579
+ /**
580
+ * Create a Workspace Events subscription and cache the result.
581
+ */
582
+ async createSpaceSubscriptionWithCache(spaceName, cacheKey) {
583
+ const authOptions = this.getAuthOptions();
584
+ this.logger?.info("createSpaceSubscriptionWithCache", {
585
+ spaceName,
586
+ hasAuthOptions: !!authOptions,
587
+ hasCredentials: !!this.credentials,
588
+ hasADC: this.useADC
589
+ });
590
+ if (!authOptions) {
591
+ this.logger?.error(
592
+ "Cannot create subscription: no auth configured. Use GOOGLE_CHAT_CREDENTIALS, GOOGLE_CHAT_USE_ADC=true, or custom auth."
593
+ );
594
+ return;
595
+ }
596
+ const pubsubTopic = this.pubsubTopic;
597
+ if (!pubsubTopic) return;
598
+ try {
599
+ const existing = await this.findExistingSubscription(
600
+ spaceName,
601
+ authOptions
602
+ );
603
+ if (existing) {
604
+ this.logger?.debug("Found existing subscription", {
605
+ spaceName,
606
+ subscriptionName: existing.subscriptionName
607
+ });
608
+ if (this.state) {
609
+ await this.state.set(
610
+ cacheKey,
611
+ existing,
612
+ SUBSCRIPTION_CACHE_TTL_MS
613
+ );
614
+ }
615
+ return;
616
+ }
617
+ this.logger?.info("Creating Workspace Events subscription", {
618
+ spaceName,
619
+ pubsubTopic
620
+ });
621
+ const result = await createSpaceSubscription(
622
+ { spaceName, pubsubTopic },
623
+ authOptions
624
+ );
625
+ const subscriptionInfo = {
626
+ subscriptionName: result.name,
627
+ expireTime: new Date(result.expireTime).getTime()
628
+ };
629
+ if (this.state) {
630
+ await this.state.set(
631
+ cacheKey,
632
+ subscriptionInfo,
633
+ SUBSCRIPTION_CACHE_TTL_MS
634
+ );
635
+ }
636
+ this.logger?.info("Workspace Events subscription created", {
637
+ spaceName,
638
+ subscriptionName: result.name,
639
+ expireTime: result.expireTime
640
+ });
641
+ } catch (error) {
642
+ this.logger?.error("Failed to create Workspace Events subscription", {
643
+ spaceName,
644
+ error
645
+ });
646
+ }
647
+ }
648
+ /**
649
+ * Check if a subscription already exists for this space.
650
+ */
651
+ async findExistingSubscription(spaceName, authOptions) {
652
+ try {
653
+ const subscriptions = await listSpaceSubscriptions(
654
+ spaceName,
655
+ authOptions
656
+ );
657
+ for (const sub of subscriptions) {
658
+ const expireTime = new Date(sub.expireTime).getTime();
659
+ if (expireTime > Date.now() + SUBSCRIPTION_REFRESH_BUFFER_MS) {
660
+ return {
661
+ subscriptionName: sub.name,
662
+ expireTime
663
+ };
664
+ }
665
+ }
666
+ } catch (error) {
667
+ this.logger?.debug("Error checking existing subscriptions", { error });
668
+ }
669
+ return null;
670
+ }
671
+ /**
672
+ * Get auth options for Workspace Events API calls.
673
+ */
674
+ getAuthOptions() {
675
+ if (this.credentials) {
676
+ return {
677
+ credentials: this.credentials,
678
+ impersonateUser: this.impersonateUser
679
+ };
680
+ }
681
+ if (this.useADC) {
682
+ return {
683
+ useApplicationDefaultCredentials: true,
684
+ impersonateUser: this.impersonateUser
685
+ };
686
+ }
687
+ if (this.customAuth) {
688
+ return { auth: this.customAuth };
689
+ }
690
+ return null;
691
+ }
692
+ async handleWebhook(request, options) {
693
+ const body = await request.text();
694
+ this.logger?.debug("GChat webhook raw body", { body });
695
+ let parsed;
696
+ try {
697
+ parsed = JSON.parse(body);
698
+ } catch {
699
+ return new Response("Invalid JSON", { status: 400 });
700
+ }
701
+ const maybePubSub = parsed;
702
+ if (maybePubSub.message?.data && maybePubSub.subscription) {
703
+ return this.handlePubSubMessage(maybePubSub, options);
704
+ }
705
+ const event = parsed;
706
+ const addedPayload = event.chat?.addedToSpacePayload;
707
+ if (addedPayload) {
708
+ this.logger?.debug("Bot added to space", {
709
+ space: addedPayload.space.name,
710
+ spaceType: addedPayload.space.type
711
+ });
712
+ this.handleAddedToSpace(addedPayload.space, options);
713
+ }
714
+ const removedPayload = event.chat?.removedFromSpacePayload;
715
+ if (removedPayload) {
716
+ this.logger?.debug("Bot removed from space", {
717
+ space: removedPayload.space.name
718
+ });
719
+ }
720
+ const buttonClickedPayload = event.chat?.buttonClickedPayload;
721
+ const invokedFunction = event.commonEventObject?.invokedFunction;
722
+ if (buttonClickedPayload || invokedFunction) {
723
+ this.handleCardClick(event, options);
724
+ return new Response(
725
+ JSON.stringify({
726
+ actionResponse: {
727
+ type: "UPDATE_MESSAGE"
728
+ }
729
+ }),
730
+ {
731
+ headers: { "Content-Type": "application/json" }
732
+ }
733
+ );
734
+ }
735
+ const messagePayload = event.chat?.messagePayload;
736
+ if (messagePayload) {
737
+ this.logger?.debug("message event", {
738
+ space: messagePayload.space.name,
739
+ sender: messagePayload.message.sender?.displayName,
740
+ text: messagePayload.message.text?.slice(0, 50)
741
+ });
742
+ this.handleMessageEvent(event, options);
743
+ } else if (!addedPayload && !removedPayload) {
744
+ this.logger?.debug("Non-message event received", {
745
+ hasChat: !!event.chat,
746
+ hasCommonEventObject: !!event.commonEventObject
747
+ });
748
+ }
749
+ return new Response(JSON.stringify({}), {
750
+ headers: { "Content-Type": "application/json" }
751
+ });
752
+ }
753
+ /**
754
+ * Handle Pub/Sub push messages from Workspace Events subscriptions.
755
+ * These contain all messages in a space, not just @mentions.
756
+ */
757
+ handlePubSubMessage(pushMessage, options) {
758
+ const eventType = pushMessage.message?.attributes?.["ce-type"];
759
+ const allowedEventTypes = [
760
+ "google.workspace.chat.message.v1.created",
761
+ "google.workspace.chat.reaction.v1.created",
762
+ "google.workspace.chat.reaction.v1.deleted"
763
+ ];
764
+ if (eventType && !allowedEventTypes.includes(eventType)) {
765
+ this.logger?.debug("Skipping unsupported Pub/Sub event", { eventType });
766
+ return new Response(JSON.stringify({ success: true }), {
767
+ headers: { "Content-Type": "application/json" }
768
+ });
769
+ }
770
+ try {
771
+ const notification = decodePubSubMessage(pushMessage);
772
+ this.logger?.debug("Pub/Sub notification decoded", {
773
+ eventType: notification.eventType,
774
+ messageId: notification.message?.name,
775
+ reactionName: notification.reaction?.name
776
+ });
777
+ if (notification.message) {
778
+ this.handlePubSubMessageEvent(notification, options);
779
+ }
780
+ if (notification.reaction) {
781
+ this.handlePubSubReactionEvent(notification, options);
782
+ }
783
+ return new Response(JSON.stringify({ success: true }), {
784
+ headers: { "Content-Type": "application/json" }
785
+ });
786
+ } catch (error) {
787
+ this.logger?.error("Error processing Pub/Sub message", { error });
788
+ return new Response(JSON.stringify({ error: "Processing failed" }), {
789
+ status: 200,
790
+ headers: { "Content-Type": "application/json" }
791
+ });
792
+ }
793
+ }
794
+ /**
795
+ * Handle message events received via Pub/Sub (Workspace Events).
796
+ */
797
+ handlePubSubMessageEvent(notification, options) {
798
+ if (!this.chat || !notification.message) {
799
+ return;
800
+ }
801
+ const message = notification.message;
802
+ const spaceName = notification.targetResource?.replace(
803
+ "//chat.googleapis.com/",
804
+ ""
805
+ );
806
+ const threadName = message.thread?.name || message.name;
807
+ const threadId = this.encodeThreadId({
808
+ spaceName: spaceName || message.space?.name || "",
809
+ threadName
810
+ });
811
+ const resolvedSpaceName = spaceName || message.space?.name;
812
+ if (resolvedSpaceName && options?.waitUntil) {
813
+ options.waitUntil(
814
+ this.ensureSpaceSubscription(resolvedSpaceName).catch((err) => {
815
+ this.logger?.debug("Subscription refresh failed", { error: err });
816
+ })
817
+ );
818
+ }
819
+ this.chat.processMessage(
820
+ this,
821
+ threadId,
822
+ () => this.parsePubSubMessage(notification, threadId),
823
+ options
824
+ );
825
+ }
826
+ /**
827
+ * Handle reaction events received via Pub/Sub (Workspace Events).
828
+ * Fetches the message to get thread context for proper reply threading.
829
+ */
830
+ handlePubSubReactionEvent(notification, options) {
831
+ if (!this.chat || !notification.reaction) {
832
+ return;
833
+ }
834
+ const reaction = notification.reaction;
835
+ const rawEmoji = reaction.emoji?.unicode || "";
836
+ const normalizedEmoji = defaultEmojiResolver.fromGChat(rawEmoji);
837
+ const reactionName = reaction.name || "";
838
+ const messageNameMatch = reactionName.match(
839
+ /(spaces\/[^/]+\/messages\/[^/]+)/
840
+ );
841
+ const messageName = messageNameMatch ? messageNameMatch[1] : "";
842
+ const spaceName = notification.targetResource?.replace(
843
+ "//chat.googleapis.com/",
844
+ ""
845
+ );
846
+ const isMe = this.botUserId !== void 0 && reaction.user?.name === this.botUserId;
847
+ const added = notification.eventType.includes("created");
848
+ const chat = this.chat;
849
+ const buildReactionEvent = async () => {
850
+ let threadId;
851
+ if (messageName) {
852
+ try {
853
+ const messageResponse = await this.chatApi.spaces.messages.get({
854
+ name: messageName
855
+ });
856
+ const threadName = messageResponse.data.thread?.name;
857
+ threadId = this.encodeThreadId({
858
+ spaceName: spaceName || "",
859
+ threadName: threadName ?? void 0
860
+ });
861
+ this.logger?.debug("Fetched thread context for reaction", {
862
+ messageName,
863
+ threadName,
864
+ threadId
865
+ });
866
+ } catch (error) {
867
+ this.logger?.warn("Failed to fetch message for thread context", {
868
+ messageName,
869
+ error
870
+ });
871
+ threadId = this.encodeThreadId({
872
+ spaceName: spaceName || ""
873
+ });
874
+ }
875
+ } else {
876
+ threadId = this.encodeThreadId({
877
+ spaceName: spaceName || ""
878
+ });
879
+ }
880
+ return {
881
+ emoji: normalizedEmoji,
882
+ rawEmoji,
883
+ added,
884
+ user: {
885
+ userId: reaction.user?.name || "unknown",
886
+ userName: reaction.user?.displayName || "unknown",
887
+ fullName: reaction.user?.displayName || "unknown",
888
+ isBot: reaction.user?.type === "BOT",
889
+ isMe
890
+ },
891
+ messageId: messageName,
892
+ threadId,
893
+ raw: notification,
894
+ adapter: this
895
+ };
896
+ };
897
+ const processTask = buildReactionEvent().then((reactionEvent) => {
898
+ chat.processReaction(reactionEvent, options);
899
+ });
900
+ if (options?.waitUntil) {
901
+ options.waitUntil(processTask);
902
+ }
903
+ }
904
+ /**
905
+ * Parse a Pub/Sub message into the standard Message format.
906
+ * Resolves user display names from cache since Pub/Sub messages don't include them.
907
+ */
908
+ async parsePubSubMessage(notification, threadId) {
909
+ const message = notification.message;
910
+ if (!message) {
911
+ throw new Error("PubSub notification missing message");
912
+ }
913
+ const text = this.normalizeBotMentions(message);
914
+ const isBot = message.sender?.type === "BOT";
915
+ const isMe = this.isMessageFromSelf(message);
916
+ const userId = message.sender?.name || "unknown";
917
+ const displayName = await this.resolveUserDisplayName(
918
+ userId,
919
+ message.sender?.displayName
920
+ );
921
+ const parsedMessage = {
922
+ id: message.name,
923
+ threadId,
924
+ text: this.formatConverter.extractPlainText(text),
925
+ formatted: this.formatConverter.toAst(text),
926
+ raw: notification,
927
+ author: {
928
+ userId,
929
+ userName: displayName,
930
+ fullName: displayName,
931
+ isBot,
932
+ isMe
933
+ },
934
+ metadata: {
935
+ dateSent: new Date(message.createTime),
936
+ edited: false
937
+ },
938
+ attachments: (message.attachment || []).map(
939
+ (att) => this.createAttachment(att)
940
+ )
941
+ };
942
+ this.logger?.debug("Pub/Sub parsed message", {
943
+ threadId,
944
+ messageId: parsedMessage.id,
945
+ text: parsedMessage.text,
946
+ author: parsedMessage.author.fullName,
947
+ isBot: parsedMessage.author.isBot,
948
+ isMe: parsedMessage.author.isMe
949
+ });
950
+ return parsedMessage;
951
+ }
952
+ /**
953
+ * Handle bot being added to a space - create Workspace Events subscription.
954
+ */
955
+ handleAddedToSpace(space, options) {
956
+ const subscribeTask = this.ensureSpaceSubscription(space.name);
957
+ if (options?.waitUntil) {
958
+ options.waitUntil(subscribeTask);
959
+ }
960
+ }
961
+ /**
962
+ * Handle card button clicks.
963
+ * Google Chat sends action data via commonEventObject.invokedFunction and parameters.
964
+ */
965
+ handleCardClick(event, options) {
966
+ if (!this.chat) {
967
+ this.logger?.warn("Chat instance not initialized, ignoring card click");
968
+ return;
969
+ }
970
+ const buttonPayload = event.chat?.buttonClickedPayload;
971
+ const commonEvent = event.commonEventObject;
972
+ const actionId = commonEvent?.invokedFunction;
973
+ if (!actionId) {
974
+ this.logger?.debug("Card click missing invokedFunction");
975
+ return;
976
+ }
977
+ const value = commonEvent?.parameters?.value;
978
+ const space = buttonPayload?.space;
979
+ const message = buttonPayload?.message;
980
+ const user = buttonPayload?.user || event.chat?.user;
981
+ if (!space) {
982
+ this.logger?.warn("Card click missing space info");
983
+ return;
984
+ }
985
+ const threadName = message?.thread?.name || message?.name;
986
+ const threadId = this.encodeThreadId({
987
+ spaceName: space.name,
988
+ threadName
989
+ });
990
+ const actionEvent = {
991
+ actionId,
992
+ value,
993
+ user: {
994
+ userId: user?.name || "unknown",
995
+ userName: user?.displayName || "unknown",
996
+ fullName: user?.displayName || "unknown",
997
+ isBot: user?.type === "BOT",
998
+ isMe: false
999
+ },
1000
+ messageId: message?.name || "",
1001
+ threadId,
1002
+ adapter: this,
1003
+ raw: event
1004
+ };
1005
+ this.logger?.debug("Processing GChat card click", {
1006
+ actionId,
1007
+ value,
1008
+ messageId: actionEvent.messageId,
1009
+ threadId
1010
+ });
1011
+ this.chat.processAction(actionEvent, options);
1012
+ }
1013
+ /**
1014
+ * Handle direct webhook message events (Add-ons format).
1015
+ */
1016
+ handleMessageEvent(event, options) {
1017
+ if (!this.chat) {
1018
+ this.logger?.warn("Chat instance not initialized, ignoring event");
1019
+ return;
1020
+ }
1021
+ const messagePayload = event.chat?.messagePayload;
1022
+ if (!messagePayload) {
1023
+ this.logger?.debug("Ignoring event without messagePayload");
1024
+ return;
1025
+ }
1026
+ const message = messagePayload.message;
1027
+ const isDM = messagePayload.space.type === "DM" || messagePayload.space.spaceType === "DIRECT_MESSAGE";
1028
+ const threadName = isDM ? void 0 : message.thread?.name || message.name;
1029
+ const threadId = this.encodeThreadId({
1030
+ spaceName: messagePayload.space.name,
1031
+ threadName,
1032
+ isDM
1033
+ });
1034
+ this.chat.processMessage(
1035
+ this,
1036
+ threadId,
1037
+ this.parseGoogleChatMessage(event, threadId),
1038
+ options
1039
+ );
1040
+ }
1041
+ parseGoogleChatMessage(event, threadId) {
1042
+ const message = event.chat?.messagePayload?.message;
1043
+ if (!message) {
1044
+ throw new Error("Event has no message payload");
1045
+ }
1046
+ const text = this.normalizeBotMentions(message);
1047
+ const isBot = message.sender?.type === "BOT";
1048
+ const isMe = this.isMessageFromSelf(message);
1049
+ const userId = message.sender?.name || "unknown";
1050
+ const displayName = message.sender?.displayName || "unknown";
1051
+ if (userId !== "unknown" && displayName !== "unknown") {
1052
+ this.cacheUserInfo(userId, displayName, message.sender?.email).catch(
1053
+ () => {
1054
+ }
1055
+ );
1056
+ }
1057
+ return {
1058
+ id: message.name,
1059
+ threadId,
1060
+ text: this.formatConverter.extractPlainText(text),
1061
+ formatted: this.formatConverter.toAst(text),
1062
+ raw: event,
1063
+ author: {
1064
+ userId,
1065
+ userName: displayName,
1066
+ fullName: displayName,
1067
+ isBot,
1068
+ isMe
1069
+ },
1070
+ metadata: {
1071
+ dateSent: new Date(message.createTime),
1072
+ edited: false
1073
+ },
1074
+ attachments: (message.attachment || []).map(
1075
+ (att) => this.createAttachment(att)
1076
+ )
1077
+ };
1078
+ }
1079
+ async postMessage(threadId, message) {
1080
+ const { spaceName, threadName } = this.decodeThreadId(threadId);
1081
+ try {
1082
+ const files = this.extractFiles(message);
1083
+ if (files.length > 0) {
1084
+ this.logger?.warn(
1085
+ "File uploads are not yet supported for Google Chat. Files will be ignored.",
1086
+ { fileCount: files.length }
1087
+ );
1088
+ }
1089
+ const card = this.extractCard(message);
1090
+ if (card) {
1091
+ const googleCard = cardToGoogleCard(card);
1092
+ this.logger?.debug("GChat API: spaces.messages.create (card)", {
1093
+ spaceName,
1094
+ threadName,
1095
+ googleCard: JSON.stringify(googleCard)
1096
+ });
1097
+ const response2 = await this.chatApi.spaces.messages.create({
1098
+ parent: spaceName,
1099
+ messageReplyOption: threadName ? "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" : void 0,
1100
+ requestBody: {
1101
+ // Don't include text - GChat shows both text and card if text is present
1102
+ cardsV2: [googleCard],
1103
+ thread: threadName ? { name: threadName } : void 0
1104
+ }
1105
+ });
1106
+ this.logger?.debug("GChat API: spaces.messages.create response", {
1107
+ messageName: response2.data.name
1108
+ });
1109
+ return {
1110
+ id: response2.data.name || "",
1111
+ threadId,
1112
+ raw: response2.data
1113
+ };
1114
+ }
1115
+ const text = convertEmojiPlaceholders2(
1116
+ this.formatConverter.renderPostable(message),
1117
+ "gchat"
1118
+ );
1119
+ this.logger?.debug("GChat API: spaces.messages.create", {
1120
+ spaceName,
1121
+ threadName,
1122
+ textLength: text.length
1123
+ });
1124
+ const response = await this.chatApi.spaces.messages.create({
1125
+ parent: spaceName,
1126
+ // Required to reply in an existing thread
1127
+ messageReplyOption: threadName ? "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" : void 0,
1128
+ requestBody: {
1129
+ text,
1130
+ thread: threadName ? { name: threadName } : void 0
1131
+ }
1132
+ });
1133
+ this.logger?.debug("GChat API: spaces.messages.create response", {
1134
+ messageName: response.data.name
1135
+ });
1136
+ return {
1137
+ id: response.data.name || "",
1138
+ threadId,
1139
+ raw: response.data
1140
+ };
1141
+ } catch (error) {
1142
+ this.handleGoogleChatError(error, "postMessage");
1143
+ }
1144
+ }
1145
+ /**
1146
+ * Extract card element from a PostableMessage if present.
1147
+ */
1148
+ extractCard(message) {
1149
+ if (isCardElement(message)) {
1150
+ return message;
1151
+ }
1152
+ if (typeof message === "object" && message !== null && "card" in message) {
1153
+ return message.card;
1154
+ }
1155
+ return null;
1156
+ }
1157
+ /**
1158
+ * Extract files from a PostableMessage if present.
1159
+ */
1160
+ extractFiles(message) {
1161
+ if (typeof message === "object" && message !== null && "files" in message) {
1162
+ return message.files ?? [];
1163
+ }
1164
+ return [];
1165
+ }
1166
+ /**
1167
+ * Create an Attachment object from a Google Chat attachment.
1168
+ */
1169
+ createAttachment(att) {
1170
+ const url = att.downloadUri || void 0;
1171
+ const authClient = this.authClient;
1172
+ let type = "file";
1173
+ if (att.contentType?.startsWith("image/")) {
1174
+ type = "image";
1175
+ } else if (att.contentType?.startsWith("video/")) {
1176
+ type = "video";
1177
+ } else if (att.contentType?.startsWith("audio/")) {
1178
+ type = "audio";
1179
+ }
1180
+ const auth = authClient;
1181
+ return {
1182
+ type,
1183
+ url,
1184
+ name: att.contentName || void 0,
1185
+ mimeType: att.contentType || void 0,
1186
+ fetchData: url ? async () => {
1187
+ if (typeof auth === "string" || !auth) {
1188
+ throw new Error("Cannot fetch file: no auth client configured");
1189
+ }
1190
+ const tokenResult = await auth.getAccessToken();
1191
+ const token = typeof tokenResult === "string" ? tokenResult : tokenResult?.token;
1192
+ if (!token) {
1193
+ throw new Error("Failed to get access token");
1194
+ }
1195
+ const response = await fetch(url, {
1196
+ headers: {
1197
+ Authorization: `Bearer ${token}`
1198
+ }
1199
+ });
1200
+ if (!response.ok) {
1201
+ throw new Error(
1202
+ `Failed to fetch file: ${response.status} ${response.statusText}`
1203
+ );
1204
+ }
1205
+ const arrayBuffer = await response.arrayBuffer();
1206
+ return Buffer.from(arrayBuffer);
1207
+ } : void 0
1208
+ };
1209
+ }
1210
+ async editMessage(threadId, messageId, message) {
1211
+ try {
1212
+ const card = this.extractCard(message);
1213
+ if (card) {
1214
+ const googleCard = cardToGoogleCard(card);
1215
+ this.logger?.debug("GChat API: spaces.messages.update (card)", {
1216
+ messageId
1217
+ });
1218
+ const response2 = await this.chatApi.spaces.messages.update({
1219
+ name: messageId,
1220
+ updateMask: "cardsV2",
1221
+ requestBody: {
1222
+ // Don't include text - GChat shows both text and card if text is present
1223
+ cardsV2: [googleCard]
1224
+ }
1225
+ });
1226
+ this.logger?.debug("GChat API: spaces.messages.update response", {
1227
+ messageName: response2.data.name
1228
+ });
1229
+ return {
1230
+ id: response2.data.name || "",
1231
+ threadId,
1232
+ raw: response2.data
1233
+ };
1234
+ }
1235
+ const text = convertEmojiPlaceholders2(
1236
+ this.formatConverter.renderPostable(message),
1237
+ "gchat"
1238
+ );
1239
+ this.logger?.debug("GChat API: spaces.messages.update", {
1240
+ messageId,
1241
+ textLength: text.length
1242
+ });
1243
+ const response = await this.chatApi.spaces.messages.update({
1244
+ name: messageId,
1245
+ updateMask: "text",
1246
+ requestBody: {
1247
+ text
1248
+ }
1249
+ });
1250
+ this.logger?.debug("GChat API: spaces.messages.update response", {
1251
+ messageName: response.data.name
1252
+ });
1253
+ return {
1254
+ id: response.data.name || "",
1255
+ threadId,
1256
+ raw: response.data
1257
+ };
1258
+ } catch (error) {
1259
+ this.handleGoogleChatError(error, "editMessage");
1260
+ }
1261
+ }
1262
+ async deleteMessage(_threadId, messageId) {
1263
+ try {
1264
+ this.logger?.debug("GChat API: spaces.messages.delete", { messageId });
1265
+ await this.chatApi.spaces.messages.delete({
1266
+ name: messageId
1267
+ });
1268
+ this.logger?.debug("GChat API: spaces.messages.delete response", {
1269
+ ok: true
1270
+ });
1271
+ } catch (error) {
1272
+ this.handleGoogleChatError(error, "deleteMessage");
1273
+ }
1274
+ }
1275
+ async addReaction(_threadId, messageId, emoji) {
1276
+ const gchatEmoji = defaultEmojiResolver.toGChat(emoji);
1277
+ try {
1278
+ this.logger?.debug("GChat API: spaces.messages.reactions.create", {
1279
+ messageId,
1280
+ emoji: gchatEmoji
1281
+ });
1282
+ await this.chatApi.spaces.messages.reactions.create({
1283
+ parent: messageId,
1284
+ requestBody: {
1285
+ emoji: { unicode: gchatEmoji }
1286
+ }
1287
+ });
1288
+ this.logger?.debug(
1289
+ "GChat API: spaces.messages.reactions.create response",
1290
+ {
1291
+ ok: true
1292
+ }
1293
+ );
1294
+ } catch (error) {
1295
+ this.handleGoogleChatError(error, "addReaction");
1296
+ }
1297
+ }
1298
+ async removeReaction(_threadId, messageId, emoji) {
1299
+ const gchatEmoji = defaultEmojiResolver.toGChat(emoji);
1300
+ try {
1301
+ this.logger?.debug("GChat API: spaces.messages.reactions.list", {
1302
+ messageId
1303
+ });
1304
+ const response = await this.chatApi.spaces.messages.reactions.list({
1305
+ parent: messageId
1306
+ });
1307
+ this.logger?.debug("GChat API: spaces.messages.reactions.list response", {
1308
+ reactionCount: response.data.reactions?.length || 0
1309
+ });
1310
+ const reaction = response.data.reactions?.find(
1311
+ (r) => r.emoji?.unicode === gchatEmoji
1312
+ );
1313
+ if (!reaction?.name) {
1314
+ this.logger?.debug("Reaction not found to remove", {
1315
+ messageId,
1316
+ emoji: gchatEmoji
1317
+ });
1318
+ return;
1319
+ }
1320
+ this.logger?.debug("GChat API: spaces.messages.reactions.delete", {
1321
+ reactionName: reaction.name
1322
+ });
1323
+ await this.chatApi.spaces.messages.reactions.delete({
1324
+ name: reaction.name
1325
+ });
1326
+ this.logger?.debug(
1327
+ "GChat API: spaces.messages.reactions.delete response",
1328
+ {
1329
+ ok: true
1330
+ }
1331
+ );
1332
+ } catch (error) {
1333
+ this.handleGoogleChatError(error, "removeReaction");
1334
+ }
1335
+ }
1336
+ async startTyping(_threadId) {
1337
+ }
1338
+ /**
1339
+ * Open a direct message conversation with a user.
1340
+ * Returns a thread ID that can be used to post messages.
1341
+ *
1342
+ * For Google Chat, this first tries to find an existing DM space with the user.
1343
+ * If no DM exists, it creates one using spaces.setup.
1344
+ *
1345
+ * @param userId - The user's resource name (e.g., "users/123456")
1346
+ */
1347
+ async openDM(userId) {
1348
+ try {
1349
+ this.logger?.debug("GChat API: spaces.findDirectMessage", { userId });
1350
+ const findResponse = await this.chatApi.spaces.findDirectMessage({
1351
+ name: userId
1352
+ });
1353
+ if (findResponse.data.name) {
1354
+ this.logger?.debug("GChat API: Found existing DM space", {
1355
+ spaceName: findResponse.data.name
1356
+ });
1357
+ return this.encodeThreadId({
1358
+ spaceName: findResponse.data.name,
1359
+ isDM: true
1360
+ });
1361
+ }
1362
+ } catch (error) {
1363
+ const gError = error;
1364
+ if (gError.code !== 404) {
1365
+ this.logger?.debug("GChat API: findDirectMessage failed", { error });
1366
+ }
1367
+ }
1368
+ const chatApi = this.impersonatedChatApi || this.chatApi;
1369
+ if (!this.impersonatedChatApi) {
1370
+ this.logger?.warn(
1371
+ "openDM: No existing DM found and no impersonation configured. Creating new DMs requires domain-wide delegation. Set 'impersonateUser' in adapter config."
1372
+ );
1373
+ }
1374
+ try {
1375
+ this.logger?.debug("GChat API: spaces.setup (DM)", {
1376
+ userId,
1377
+ hasImpersonation: !!this.impersonatedChatApi,
1378
+ impersonateUser: this.impersonateUser
1379
+ });
1380
+ const response = await chatApi.spaces.setup({
1381
+ requestBody: {
1382
+ space: {
1383
+ spaceType: "DIRECT_MESSAGE"
1384
+ },
1385
+ memberships: [
1386
+ {
1387
+ member: {
1388
+ name: userId,
1389
+ type: "HUMAN"
1390
+ }
1391
+ }
1392
+ ]
1393
+ }
1394
+ });
1395
+ const spaceName = response.data.name;
1396
+ if (!spaceName) {
1397
+ throw new Error("Failed to create DM - no space name returned");
1398
+ }
1399
+ this.logger?.debug("GChat API: spaces.setup response", { spaceName });
1400
+ return this.encodeThreadId({ spaceName, isDM: true });
1401
+ } catch (error) {
1402
+ this.handleGoogleChatError(error, "openDM");
1403
+ }
1404
+ }
1405
+ async fetchMessages(threadId, options = {}) {
1406
+ const { spaceName } = this.decodeThreadId(threadId);
1407
+ try {
1408
+ this.logger?.debug("GChat API: spaces.messages.list", {
1409
+ spaceName,
1410
+ pageSize: options.limit || 100
1411
+ });
1412
+ const response = await this.chatApi.spaces.messages.list({
1413
+ parent: spaceName,
1414
+ pageSize: options.limit || 100,
1415
+ pageToken: options.before
1416
+ });
1417
+ const messages = response.data.messages || [];
1418
+ this.logger?.debug("GChat API: spaces.messages.list response", {
1419
+ messageCount: messages.length
1420
+ });
1421
+ return messages.map((msg) => {
1422
+ const msgThreadId = this.encodeThreadId({
1423
+ spaceName,
1424
+ threadName: msg.thread?.name ?? void 0
1425
+ });
1426
+ const msgIsBot = msg.sender?.type === "BOT";
1427
+ return {
1428
+ id: msg.name || "",
1429
+ threadId: msgThreadId,
1430
+ text: this.formatConverter.extractPlainText(msg.text || ""),
1431
+ formatted: this.formatConverter.toAst(msg.text || ""),
1432
+ raw: msg,
1433
+ author: {
1434
+ userId: msg.sender?.name || "unknown",
1435
+ userName: msg.sender?.displayName || "unknown",
1436
+ fullName: msg.sender?.displayName || "unknown",
1437
+ isBot: msgIsBot,
1438
+ isMe: msgIsBot
1439
+ },
1440
+ metadata: {
1441
+ dateSent: msg.createTime ? new Date(msg.createTime) : /* @__PURE__ */ new Date(),
1442
+ edited: false
1443
+ },
1444
+ attachments: []
1445
+ };
1446
+ });
1447
+ } catch (error) {
1448
+ this.handleGoogleChatError(error, "fetchMessages");
1449
+ }
1450
+ }
1451
+ async fetchThread(threadId) {
1452
+ const { spaceName } = this.decodeThreadId(threadId);
1453
+ try {
1454
+ this.logger?.debug("GChat API: spaces.get", { spaceName });
1455
+ const response = await this.chatApi.spaces.get({ name: spaceName });
1456
+ this.logger?.debug("GChat API: spaces.get response", {
1457
+ displayName: response.data.displayName
1458
+ });
1459
+ return {
1460
+ id: threadId,
1461
+ channelId: spaceName,
1462
+ channelName: response.data.displayName ?? void 0,
1463
+ metadata: {
1464
+ space: response.data
1465
+ }
1466
+ };
1467
+ } catch (error) {
1468
+ this.handleGoogleChatError(error, "fetchThread");
1469
+ }
1470
+ }
1471
+ encodeThreadId(platformData) {
1472
+ const threadPart = platformData.threadName ? `:${Buffer.from(platformData.threadName).toString("base64url")}` : "";
1473
+ const dmPart = platformData.isDM ? ":dm" : "";
1474
+ return `gchat:${platformData.spaceName}${threadPart}${dmPart}`;
1475
+ }
1476
+ /**
1477
+ * Check if a thread is a direct message conversation.
1478
+ * Checks for the :dm marker in the thread ID which is set when
1479
+ * processing DM messages or opening DMs.
1480
+ */
1481
+ isDM(threadId) {
1482
+ return threadId.endsWith(":dm");
1483
+ }
1484
+ decodeThreadId(threadId) {
1485
+ const isDM = threadId.endsWith(":dm");
1486
+ const cleanId = isDM ? threadId.slice(0, -3) : threadId;
1487
+ const parts = cleanId.split(":");
1488
+ if (parts.length < 2 || parts[0] !== "gchat") {
1489
+ throw new Error(`Invalid Google Chat thread ID: ${threadId}`);
1490
+ }
1491
+ const spaceName = parts[1];
1492
+ const threadName = parts[2] ? Buffer.from(parts[2], "base64url").toString("utf-8") : void 0;
1493
+ return { spaceName, threadName, isDM };
1494
+ }
1495
+ parseMessage(raw) {
1496
+ const event = raw;
1497
+ const messagePayload = event.chat?.messagePayload;
1498
+ if (!messagePayload) {
1499
+ throw new Error("Cannot parse non-message event");
1500
+ }
1501
+ const threadName = messagePayload.message.thread?.name || messagePayload.message.name;
1502
+ const threadId = this.encodeThreadId({
1503
+ spaceName: messagePayload.space.name,
1504
+ threadName
1505
+ });
1506
+ return this.parseGoogleChatMessage(event, threadId);
1507
+ }
1508
+ renderFormatted(content) {
1509
+ return this.formatConverter.fromAst(content);
1510
+ }
1511
+ /**
1512
+ * Normalize bot mentions in message text.
1513
+ * Google Chat uses the bot's display name (e.g., "@Chat SDK Demo") but the
1514
+ * Chat SDK expects "@{userName}" format. This method replaces bot mentions
1515
+ * with the adapter's userName so mention detection works properly.
1516
+ * Also learns the bot's user ID from annotations for isMe detection.
1517
+ */
1518
+ normalizeBotMentions(message) {
1519
+ let text = message.text || "";
1520
+ const annotations = message.annotations || [];
1521
+ for (const annotation of annotations) {
1522
+ if (annotation.type === "USER_MENTION" && annotation.userMention?.user?.type === "BOT") {
1523
+ const botUser = annotation.userMention.user;
1524
+ const botDisplayName = botUser.displayName;
1525
+ if (botUser.name && !this.botUserId) {
1526
+ this.botUserId = botUser.name;
1527
+ this.logger?.info("Learned bot user ID from mention", {
1528
+ botUserId: this.botUserId
1529
+ });
1530
+ this.state?.set("gchat:botUserId", this.botUserId).catch(
1531
+ (err) => this.logger?.debug("Failed to persist botUserId", { error: err })
1532
+ );
1533
+ }
1534
+ if (annotation.startIndex !== void 0 && annotation.length !== void 0) {
1535
+ const startIndex = annotation.startIndex;
1536
+ const length = annotation.length;
1537
+ const mentionText = text.slice(startIndex, startIndex + length);
1538
+ text = text.slice(0, startIndex) + `@${this.userName}` + text.slice(startIndex + length);
1539
+ this.logger?.debug("Normalized bot mention", {
1540
+ original: mentionText,
1541
+ replacement: `@${this.userName}`
1542
+ });
1543
+ } else if (botDisplayName) {
1544
+ const mentionText = `@${botDisplayName}`;
1545
+ text = text.replace(mentionText, `@${this.userName}`);
1546
+ }
1547
+ }
1548
+ }
1549
+ return text;
1550
+ }
1551
+ /**
1552
+ * Check if a message is from this bot.
1553
+ *
1554
+ * Bot user ID is learned dynamically from message annotations when the bot
1555
+ * is @mentioned. Until we learn the ID, we cannot reliably determine isMe.
1556
+ *
1557
+ * This is safer than the previous approach of assuming all BOT messages are
1558
+ * from self, which would incorrectly filter messages from other bots in
1559
+ * multi-bot spaces (especially via Pub/Sub).
1560
+ */
1561
+ isMessageFromSelf(message) {
1562
+ const senderId = message.sender?.name;
1563
+ if (this.botUserId && senderId) {
1564
+ return senderId === this.botUserId;
1565
+ }
1566
+ if (!this.botUserId && message.sender?.type === "BOT") {
1567
+ this.logger?.debug(
1568
+ "Cannot determine isMe - bot user ID not yet learned. Bot ID is learned from @mentions. Assuming message is not from self.",
1569
+ { senderId }
1570
+ );
1571
+ }
1572
+ return false;
1573
+ }
1574
+ /**
1575
+ * Cache user info for later lookup (e.g., when processing Pub/Sub messages).
1576
+ */
1577
+ async cacheUserInfo(userId, displayName, email) {
1578
+ if (!this.state || !displayName || displayName === "unknown") return;
1579
+ const cacheKey = `${USER_INFO_KEY_PREFIX}${userId}`;
1580
+ await this.state.set(
1581
+ cacheKey,
1582
+ { displayName, email },
1583
+ USER_INFO_CACHE_TTL_MS
1584
+ );
1585
+ }
1586
+ /**
1587
+ * Get cached user info.
1588
+ */
1589
+ async getCachedUserInfo(userId) {
1590
+ if (!this.state) return null;
1591
+ const cacheKey = `${USER_INFO_KEY_PREFIX}${userId}`;
1592
+ return this.state.get(cacheKey);
1593
+ }
1594
+ /**
1595
+ * Resolve user display name, using cache if available.
1596
+ */
1597
+ async resolveUserDisplayName(userId, providedDisplayName) {
1598
+ if (providedDisplayName && providedDisplayName !== "unknown") {
1599
+ this.cacheUserInfo(userId, providedDisplayName).catch(() => {
1600
+ });
1601
+ return providedDisplayName;
1602
+ }
1603
+ const cached = await this.getCachedUserInfo(userId);
1604
+ if (cached?.displayName) {
1605
+ return cached.displayName;
1606
+ }
1607
+ return userId.replace("users/", "User ");
1608
+ }
1609
+ handleGoogleChatError(error, context) {
1610
+ const gError = error;
1611
+ this.logger?.error(`GChat API error${context ? ` (${context})` : ""}`, {
1612
+ code: gError.code,
1613
+ message: gError.message,
1614
+ errors: gError.errors,
1615
+ error
1616
+ });
1617
+ if (gError.code === 429) {
1618
+ throw new RateLimitError(
1619
+ "Google Chat rate limit exceeded",
1620
+ void 0,
1621
+ error
1622
+ );
1623
+ }
1624
+ throw error;
1625
+ }
1626
+ };
1627
+ function createGoogleChatAdapter(config) {
1628
+ return new GoogleChatAdapter(config);
1629
+ }
1630
+ export {
1631
+ GoogleChatAdapter,
1632
+ GoogleChatFormatConverter,
1633
+ cardToFallbackText,
1634
+ cardToGoogleCard,
1635
+ createGoogleChatAdapter,
1636
+ createSpaceSubscription,
1637
+ decodePubSubMessage,
1638
+ deleteSpaceSubscription,
1639
+ listSpaceSubscriptions,
1640
+ verifyPubSubRequest
1641
+ };
1642
+ //# sourceMappingURL=index.js.map