@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.d.ts +567 -0
- package/dist/index.js +1642 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
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
|