crewly 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
  2. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +7 -0
  3. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
  4. package/dist/backend/backend/src/controllers/orchestrator/orchestrator.controller.d.ts +16 -0
  5. package/dist/backend/backend/src/controllers/orchestrator/orchestrator.controller.d.ts.map +1 -1
  6. package/dist/backend/backend/src/controllers/orchestrator/orchestrator.controller.js +124 -13
  7. package/dist/backend/backend/src/controllers/orchestrator/orchestrator.controller.js.map +1 -1
  8. package/dist/backend/backend/src/services/cloud/cloud-auth.middleware.js +1 -1
  9. package/dist/backend/backend/src/services/cloud/cloud-auth.middleware.js.map +1 -1
  10. package/dist/backend/backend/src/services/cloud/relay-client.service.d.ts.map +1 -1
  11. package/dist/backend/backend/src/services/cloud/relay-client.service.js +5 -3
  12. package/dist/backend/backend/src/services/cloud/relay-client.service.js.map +1 -1
  13. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.d.ts +212 -24
  14. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.d.ts.map +1 -1
  15. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.js +705 -85
  16. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.js.map +1 -1
  17. package/dist/backend/backend/src/services/session/session-handoff.service.d.ts +35 -1
  18. package/dist/backend/backend/src/services/session/session-handoff.service.d.ts.map +1 -1
  19. package/dist/backend/backend/src/services/session/session-handoff.service.js +113 -4
  20. package/dist/backend/backend/src/services/session/session-handoff.service.js.map +1 -1
  21. package/frontend/dist/assets/index-c35cdbc5.js +5213 -0
  22. package/frontend/dist/assets/{index-60a9e4ea.css → index-f1dc9f80.css} +1 -1
  23. package/frontend/dist/index.html +2 -2
  24. package/package.json +1 -1
  25. package/frontend/dist/assets/index-1d23cce8.js +0 -4919
@@ -1,93 +1,197 @@
1
1
  /**
2
2
  * Google Chat Messenger Adapter
3
3
  *
4
- * Messenger adapter for Google Chat using the Google Chat API (webhook or service account).
5
- * Supports sending messages to Google Chat spaces via incoming webhooks or
6
- * the Chat API with a service account.
4
+ * Messenger adapter for Google Chat supporting three connection modes:
5
+ * 1. **Webhook mode** post messages via an incoming webhook URL (send-only)
6
+ * 2. **Service account mode** — send messages via the Chat API (send-only)
7
+ * 3. **Pub/Sub mode** — pull incoming messages from a Cloud Pub/Sub subscription
8
+ * and reply via the Chat API (bidirectional, thread-aware)
9
+ *
10
+ * In Pub/Sub mode, a Google Chat App is configured to publish events to a
11
+ * Pub/Sub topic. Crewly pulls from the subscription, processes MESSAGE events,
12
+ * and replies in the same thread via the Chat API.
7
13
  *
8
14
  * @module services/messaging/adapters/google-chat-messenger.adapter
9
15
  */
10
- /** Timeout for external Google Chat API calls (ms). */
11
- const FETCH_TIMEOUT_MS = 15_000;
16
+ import { GOOGLE_CHAT_PUBSUB_CONSTANTS } from '../../../constants.js';
17
+ import { LoggerService } from '../../core/logger.service.js';
18
+ const logger = LoggerService.getInstance().createComponentLogger('GoogleChatPubSub');
12
19
  /**
13
- * Messenger adapter for Google Chat.
14
- *
15
- * Supports two modes:
16
- * 1. **Webhook mode** (simpler): uses an incoming webhook URL to post messages
17
- * 2. **Service account mode**: uses a service account key to call the Chat API
20
+ * Messenger adapter for Google Chat with Pub/Sub support.
18
21
  *
19
- * Webhook mode is used when `webhookUrl` is provided in config.
20
- * Service account mode is used when `serviceAccountKey` is provided.
22
+ * Supports webhook, service-account, and pubsub modes.
23
+ * Pub/Sub mode enables bidirectional communication with thread tracking.
21
24
  */
22
25
  export class GoogleChatMessengerAdapter {
23
26
  platform = 'google-chat';
27
+ /** Current connection mode */
28
+ mode = 'none';
24
29
  /** Webhook URL for posting messages (webhook mode) */
25
30
  webhookUrl = null;
26
- /** Service account key JSON (service account mode) */
31
+ /** Service account key JSON string */
27
32
  serviceAccountKey = null;
28
- /** Access token obtained from service account (cached) */
33
+ /** Authentication mode: service_account (JWT) or adc (refresh token) */
34
+ authMode = 'service_account';
35
+ /** ADC credentials (parsed from application_default_credentials.json) */
36
+ adcCredentials = null;
37
+ /** Access token obtained from service account or ADC (cached) */
29
38
  accessToken = null;
30
39
  /** Access token expiry timestamp */
31
40
  tokenExpiresAt = 0;
41
+ /** OAuth2 scopes for the current mode */
42
+ tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
43
+ /** Pending token refresh promise to deduplicate concurrent requests (RL2) */
44
+ pendingTokenRefresh = null;
45
+ /** Full Pub/Sub subscription resource name (e.g. projects/PROJECT/subscriptions/SUB) */
46
+ subscriptionName = null;
47
+ /** GCP project ID (for Pub/Sub mode) */
48
+ projectId = null;
49
+ /** Pub/Sub pull interval timer */
50
+ pullIntervalTimer = null;
51
+ /** Callback for incoming messages */
52
+ onIncomingMessage = null;
53
+ /** Service account email for impersonation (ADC mode) */
54
+ serviceAccountEmail = null;
55
+ /** Impersonated SA token (separate from user ADC token) */
56
+ saAccessToken = null;
57
+ /** Impersonated SA token expiry timestamp */
58
+ saTokenExpiresAt = 0;
59
+ /**
60
+ * Recent send deduplication cache.
61
+ * Key: `${space}:${threadId}:${contentPrefix}`, value: timestamp (epoch ms).
62
+ * Prevents the same message being sent twice within DEDUP_WINDOW_MS (e.g. when
63
+ * both the auto-route and a manual reply-gchat call fire for the same response).
64
+ */
65
+ recentSends = new Map();
66
+ /** Deduplication window in milliseconds */
67
+ static DEDUP_WINDOW_MS = 10_000;
68
+ /** Consecutive pull failure count */
69
+ consecutiveFailures = 0;
70
+ /** Whether the pull loop is paused due to failures */
71
+ pullPaused = false;
72
+ /** Timestamp of the last successful pull (epoch ms) */
73
+ lastPullAt = null;
32
74
  /**
33
- * Initialize the adapter by validating credentials.
75
+ * Initialize the adapter with the provided credentials.
76
+ *
77
+ * Detects mode based on provided config fields:
78
+ * - `webhookUrl` → webhook mode
79
+ * - `serviceAccountKey` + `projectId` + `subscriptionName` → pubsub mode
80
+ * - `serviceAccountKey` alone → service-account mode
34
81
  *
35
- * @param config - Must contain either `webhookUrl` string or `serviceAccountKey` string
36
- * @throws Error if neither credential is provided or validation fails
82
+ * @param config - Configuration object with credentials
83
+ * @throws Error if credentials are invalid or missing
37
84
  */
38
85
  async initialize(config) {
39
86
  const webhookUrl = config.webhookUrl;
40
87
  const serviceAccountKey = config.serviceAccountKey;
88
+ const projectId = config.projectId;
89
+ const subscriptionName = config.subscriptionName;
90
+ const onIncomingMessage = config.onIncomingMessage;
91
+ const authMode = (config.authMode === 'adc' ? 'adc' : 'service_account');
92
+ // Webhook mode
41
93
  if (typeof webhookUrl === 'string' && webhookUrl) {
42
- // Webhook mode: validate the URL format
43
94
  if (!webhookUrl.startsWith('https://chat.googleapis.com/')) {
44
95
  throw new Error('Invalid Google Chat webhook URL. Must start with https://chat.googleapis.com/');
45
96
  }
46
- // Validate by sending a test (dry-run) - Google Chat webhooks don't have a validate endpoint,
47
- // so we just validate the URL format and store it
97
+ this.resetState();
48
98
  this.webhookUrl = webhookUrl;
49
- this.serviceAccountKey = null;
50
- this.accessToken = null;
99
+ this.mode = 'webhook';
51
100
  return;
52
101
  }
53
- if (typeof serviceAccountKey === 'string' && serviceAccountKey) {
54
- // Service account mode: validate the key is valid JSON
55
- try {
56
- const parsed = JSON.parse(serviceAccountKey);
57
- if (!parsed.client_email || !parsed.private_key) {
58
- throw new Error('Service account key must contain client_email and private_key');
59
- }
102
+ // ADC mode use Application Default Credentials (no SA key needed)
103
+ if (authMode === 'adc') {
104
+ const adcCreds = await this.loadAdcCredentials();
105
+ this.resetState();
106
+ this.authMode = 'adc';
107
+ this.adcCredentials = adcCreds;
108
+ // Store optional SA email for impersonation
109
+ if (typeof config.serviceAccountEmail === 'string' && config.serviceAccountEmail) {
110
+ this.serviceAccountEmail = config.serviceAccountEmail;
60
111
  }
61
- catch (err) {
62
- if (err instanceof SyntaxError) {
63
- throw new Error('Service account key must be valid JSON');
112
+ // Pub/Sub mode: requires projectId + subscriptionName
113
+ if (typeof projectId === 'string' && projectId &&
114
+ typeof subscriptionName === 'string' && subscriptionName) {
115
+ this.projectId = projectId;
116
+ this.subscriptionName = `projects/${projectId}/subscriptions/${subscriptionName}`;
117
+ this.tokenScopes = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE} ${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_SCOPE}`;
118
+ this.mode = 'pubsub';
119
+ if (typeof onIncomingMessage === 'function') {
120
+ this.onIncomingMessage = onIncomingMessage;
64
121
  }
65
- throw err;
122
+ this.startPubSubPull();
123
+ return;
66
124
  }
125
+ // Service account mode with ADC auth (send-only)
126
+ this.mode = 'service-account';
127
+ this.tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
128
+ return;
129
+ }
130
+ // Service Account mode — requires a service account key
131
+ if (typeof serviceAccountKey === 'string' && serviceAccountKey) {
132
+ this.validateServiceAccountKey(serviceAccountKey);
133
+ this.resetState();
134
+ this.authMode = 'service_account';
67
135
  this.serviceAccountKey = serviceAccountKey;
68
- this.webhookUrl = null;
69
- this.accessToken = null;
136
+ // Pub/Sub mode: requires projectId + subscriptionName
137
+ if (typeof projectId === 'string' && projectId &&
138
+ typeof subscriptionName === 'string' && subscriptionName) {
139
+ this.projectId = projectId;
140
+ this.subscriptionName = `projects/${projectId}/subscriptions/${subscriptionName}`;
141
+ this.tokenScopes = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE} ${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_SCOPE}`;
142
+ this.mode = 'pubsub';
143
+ if (typeof onIncomingMessage === 'function') {
144
+ this.onIncomingMessage = onIncomingMessage;
145
+ }
146
+ this.startPubSubPull();
147
+ return;
148
+ }
149
+ // Service account mode (send-only)
150
+ this.mode = 'service-account';
151
+ this.tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
70
152
  return;
71
153
  }
72
- throw new Error('Google Chat requires either a webhookUrl or serviceAccountKey');
154
+ throw new Error('Google Chat requires either a webhookUrl, serviceAccountKey, or authMode: adc');
73
155
  }
74
156
  /**
75
157
  * Send a text message to a Google Chat space.
76
158
  *
77
159
  * In webhook mode, the `channel` parameter is ignored (webhook URL determines the space).
78
- * In service account mode, `channel` is the space name (e.g., "spaces/AAAA...").
160
+ * In service-account/pubsub mode, `channel` is the space name (e.g., "spaces/AAAA...").
79
161
  *
80
- * @param channel - Google Chat space name (used in service account mode)
162
+ * @param channel - Google Chat space name (used in service-account/pubsub mode)
81
163
  * @param text - Message content
82
164
  * @param options - Optional send options (threadId for threaded replies)
83
165
  * @throws Error if adapter is not initialized or send fails
84
166
  */
85
167
  async sendMessage(channel, text, options) {
86
- if (this.webhookUrl) {
168
+ // Dedup: skip if the same message was sent to the same space+thread recently.
169
+ // This prevents double-sends when both the auto-route (googleChatResolve) and
170
+ // a manual reply-gchat API call fire for the same response.
171
+ const dedupKey = `${channel}:${options?.threadId || ''}:${text.substring(0, 200)}`;
172
+ const now = Date.now();
173
+ const lastSent = this.recentSends.get(dedupKey);
174
+ if (lastSent && (now - lastSent) < GoogleChatMessengerAdapter.DEDUP_WINDOW_MS) {
175
+ logger.info('Skipping duplicate send (same message sent within dedup window)', {
176
+ channel,
177
+ threadId: options?.threadId,
178
+ windowMs: GoogleChatMessengerAdapter.DEDUP_WINDOW_MS,
179
+ });
180
+ return;
181
+ }
182
+ this.recentSends.set(dedupKey, now);
183
+ // Evict stale entries to prevent unbounded growth
184
+ for (const [key, ts] of this.recentSends) {
185
+ if (now - ts > GoogleChatMessengerAdapter.DEDUP_WINDOW_MS) {
186
+ this.recentSends.delete(key);
187
+ }
188
+ }
189
+ if (this.mode === 'webhook' && this.webhookUrl) {
87
190
  await this.sendViaWebhook(text, options?.threadId);
88
191
  return;
89
192
  }
90
- if (this.serviceAccountKey) {
193
+ if ((this.mode === 'service-account' || this.mode === 'pubsub') &&
194
+ (this.serviceAccountKey || this.adcCredentials)) {
91
195
  await this.sendViaApi(channel, text, options?.threadId);
92
196
  return;
93
197
  }
@@ -96,64 +200,287 @@ export class GoogleChatMessengerAdapter {
96
200
  /**
97
201
  * Get the current connection status.
98
202
  *
99
- * @returns Status object with connected flag and platform identifier
203
+ * @returns Status object with connected flag, platform, and mode details
100
204
  */
101
205
  getStatus() {
102
- const connected = Boolean(this.webhookUrl || this.serviceAccountKey);
103
206
  return {
104
- connected,
207
+ connected: this.mode !== 'none',
105
208
  platform: this.platform,
106
209
  details: {
107
- mode: this.webhookUrl ? 'webhook' : this.serviceAccountKey ? 'service-account' : 'none',
210
+ mode: this.mode,
211
+ authMode: this.authMode,
212
+ ...(this.serviceAccountEmail ? { serviceAccountEmail: this.serviceAccountEmail } : {}),
213
+ ...(this.mode === 'pubsub' ? {
214
+ subscriptionName: this.subscriptionName,
215
+ projectId: this.projectId,
216
+ pullActive: Boolean(this.pullIntervalTimer) && !this.pullPaused,
217
+ pullPaused: this.pullPaused,
218
+ consecutiveFailures: this.consecutiveFailures,
219
+ lastPullAt: this.lastPullAt ? new Date(this.lastPullAt).toISOString() : null,
220
+ } : {}),
108
221
  },
109
222
  };
110
223
  }
111
224
  /**
112
- * Disconnect by clearing stored credentials.
225
+ * Disconnect by clearing stored credentials and stopping the pull loop.
113
226
  */
114
227
  async disconnect() {
115
- this.webhookUrl = null;
116
- this.serviceAccountKey = null;
117
- this.accessToken = null;
118
- this.tokenExpiresAt = 0;
228
+ this.stopPubSubPull();
229
+ this.resetState();
119
230
  }
120
231
  /**
121
232
  * Add an emoji reaction to a Google Chat message.
122
233
  *
123
- * Only works in service account mode — webhook mode does not support the
234
+ * Only works in service-account/pubsub mode — webhook mode does not support the
124
235
  * reactions API. Calls `spaces.messages.reactions.create`.
125
236
  *
126
237
  * @param messageName - Full message resource name (e.g., "spaces/xxx/messages/yyy")
127
238
  * @param emoji - Unicode emoji character (e.g., "👀", "✅")
128
239
  */
129
240
  async addReaction(messageName, emoji) {
130
- if (!this.serviceAccountKey) {
131
- // Reactions require service account mode — silently skip in webhook mode
241
+ if (!this.serviceAccountKey && !this.adcCredentials) {
242
+ // Reactions require service account or ADC mode — silently skip in webhook mode
132
243
  return;
133
244
  }
134
245
  if (!messageName || !messageName.includes('/messages/')) {
135
246
  return;
136
247
  }
137
248
  const token = await this.getAccessToken();
138
- const resp = await fetch(`https://chat.googleapis.com/v1/${messageName}/reactions`, {
249
+ const resp = await fetch(`${GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_API_BASE}/${messageName}/reactions`, {
139
250
  method: 'POST',
140
- headers: {
141
- Authorization: `Bearer ${token}`,
142
- 'Content-Type': 'application/json',
143
- },
251
+ headers: this.getAuthHeaders(token),
144
252
  body: JSON.stringify({
145
253
  emoji: { unicode: emoji },
146
254
  }),
147
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
255
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
148
256
  });
149
257
  if (!resp.ok) {
150
258
  const details = await resp.text();
151
- // Non-critical — don't throw, just log
259
+ // Non-critical — don't throw for duplicates
152
260
  if (!details.includes('ALREADY_EXISTS')) {
153
261
  throw new Error(`Google Chat reaction failed (${resp.status}): ${details}`);
154
262
  }
155
263
  }
156
264
  }
265
+ // ===========================================================================
266
+ // Pub/Sub Pull Loop
267
+ // ===========================================================================
268
+ /**
269
+ * Start the Pub/Sub pull loop that periodically fetches messages.
270
+ */
271
+ startPubSubPull() {
272
+ this.stopPubSubPull();
273
+ this.consecutiveFailures = 0;
274
+ this.pullPaused = false;
275
+ logger.info('Starting Pub/Sub pull loop', {
276
+ subscription: this.subscriptionName,
277
+ intervalMs: GOOGLE_CHAT_PUBSUB_CONSTANTS.PULL_INTERVAL_MS,
278
+ maxFailures: GOOGLE_CHAT_PUBSUB_CONSTANTS.MAX_CONSECUTIVE_FAILURES,
279
+ });
280
+ this.pullIntervalTimer = setInterval(async () => {
281
+ if (this.pullPaused)
282
+ return;
283
+ try {
284
+ await this.pullMessages();
285
+ this.consecutiveFailures = 0;
286
+ }
287
+ catch (error) {
288
+ // AbortError / TimeoutError from AbortSignal.timeout() is a normal
289
+ // "no messages within the timeout window" — not a real failure.
290
+ const isTimeout = error instanceof DOMException && (error.name === 'AbortError' || error.name === 'TimeoutError');
291
+ if (isTimeout) {
292
+ // Normal timeout — reset failure counter and skip error handling
293
+ this.consecutiveFailures = 0;
294
+ logger.debug('Pull timed out (no messages)', { subscription: this.subscriptionName });
295
+ return;
296
+ }
297
+ this.consecutiveFailures++;
298
+ const errorMsg = error instanceof Error ? error.message : String(error);
299
+ logger.error('Pull loop failure', {
300
+ subscription: this.subscriptionName,
301
+ consecutiveFailures: this.consecutiveFailures,
302
+ error: errorMsg,
303
+ });
304
+ if (this.consecutiveFailures >= GOOGLE_CHAT_PUBSUB_CONSTANTS.MAX_CONSECUTIVE_FAILURES) {
305
+ this.pullPaused = true;
306
+ logger.error('Pull loop PAUSED due to repeated failures', {
307
+ subscription: this.subscriptionName,
308
+ consecutiveFailures: this.consecutiveFailures,
309
+ });
310
+ }
311
+ }
312
+ }, GOOGLE_CHAT_PUBSUB_CONSTANTS.PULL_INTERVAL_MS);
313
+ }
314
+ /**
315
+ * Stop the Pub/Sub pull loop.
316
+ */
317
+ stopPubSubPull() {
318
+ if (this.pullIntervalTimer) {
319
+ logger.info('Stopping Pub/Sub pull loop', { subscription: this.subscriptionName });
320
+ clearInterval(this.pullIntervalTimer);
321
+ this.pullIntervalTimer = null;
322
+ }
323
+ }
324
+ /**
325
+ * Pull messages from the Pub/Sub subscription, process them, and acknowledge.
326
+ *
327
+ * Each message is a base64-encoded Google Chat event JSON. Only MESSAGE-type
328
+ * events are forwarded to the incoming message callback. All messages are
329
+ * acknowledged regardless of type to prevent redelivery.
330
+ */
331
+ async pullMessages() {
332
+ if (!this.subscriptionName) {
333
+ throw new Error('Pub/Sub subscription not configured');
334
+ }
335
+ logger.debug('Pulling messages', { subscription: this.subscriptionName });
336
+ const token = await this.getAccessToken();
337
+ const pullUrl = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_API_BASE}/${this.subscriptionName}:pull`;
338
+ const resp = await fetch(pullUrl, {
339
+ method: 'POST',
340
+ headers: this.getAuthHeaders(token),
341
+ body: JSON.stringify({
342
+ maxMessages: GOOGLE_CHAT_PUBSUB_CONSTANTS.MAX_MESSAGES_PER_PULL,
343
+ returnImmediately: true,
344
+ }),
345
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
346
+ });
347
+ if (!resp.ok) {
348
+ const details = await resp.text();
349
+ throw new Error(`Pub/Sub pull failed (${resp.status}): ${details}`);
350
+ }
351
+ const data = await resp.json();
352
+ this.lastPullAt = Date.now();
353
+ if (!data.receivedMessages || data.receivedMessages.length === 0) {
354
+ return 0;
355
+ }
356
+ const messageCount = data.receivedMessages.length;
357
+ const receivedAt = new Date().toISOString();
358
+ logger.info('Received messages from Pub/Sub', {
359
+ count: messageCount,
360
+ subscription: this.subscriptionName,
361
+ receivedAt,
362
+ });
363
+ const ackIds = [];
364
+ for (const received of data.receivedMessages) {
365
+ ackIds.push(received.ackId);
366
+ if (!received.message.data)
367
+ continue;
368
+ try {
369
+ const decoded = Buffer.from(received.message.data, 'base64').toString('utf-8');
370
+ logger.info('Decoded Pub/Sub payload', {
371
+ messageId: received.message.messageId,
372
+ snippet: decoded.slice(0, 200),
373
+ });
374
+ const event = JSON.parse(decoded);
375
+ logger.info('Parsed chat event', {
376
+ keys: Object.keys(event),
377
+ type: event.type,
378
+ hasMessage: Boolean(event.message),
379
+ hasText: Boolean(event.message?.text),
380
+ space: event.space?.name,
381
+ // v2 format detection
382
+ hasChat: Boolean(event.chat),
383
+ chatKeys: event.chat ? Object.keys(event.chat) : 'none',
384
+ hasCommonEventObject: Boolean(event.commonEventObject),
385
+ });
386
+ this.processChatEvent(event);
387
+ }
388
+ catch (err) {
389
+ logger.error('Failed to parse Pub/Sub message', {
390
+ messageId: received.message.messageId,
391
+ error: err instanceof Error ? err.message : String(err),
392
+ dataSnippet: received.message.data?.slice(0, 100),
393
+ });
394
+ }
395
+ }
396
+ // Acknowledge all messages (even non-MESSAGE types) to prevent redelivery
397
+ if (ackIds.length > 0) {
398
+ await this.acknowledgeMessages(ackIds);
399
+ }
400
+ return messageCount;
401
+ }
402
+ /**
403
+ * Process a Google Chat event and forward MESSAGE events to the callback.
404
+ *
405
+ * Supports two payload formats:
406
+ * - **Legacy (v1)**: `{ type: 'MESSAGE', message: { text, sender, thread }, space }`
407
+ * - **v2**: `{ commonEventObject: {...}, chat: { messagePayload: { message: {...}, space: {...} } } }`
408
+ *
409
+ * Both formats are normalized into the same IncomingMessage shape.
410
+ *
411
+ * @param event - Parsed Google Chat event payload (legacy or v2)
412
+ */
413
+ processChatEvent(event) {
414
+ // Detect format and extract message + space
415
+ const isV2 = Boolean(event.chat?.messagePayload);
416
+ const msg = isV2 ? event.chat?.messagePayload?.message : event.message;
417
+ const space = isV2 ? event.chat?.messagePayload?.space : event.space;
418
+ // Legacy format: check type field. v2 format: check if messagePayload exists (implies MESSAGE)
419
+ const isMessage = isV2
420
+ ? Boolean(msg?.text)
421
+ : (event.type === 'MESSAGE' && Boolean(msg?.text));
422
+ if (!isMessage || !msg?.text) {
423
+ logger.info('Skipping non-MESSAGE event', {
424
+ format: isV2 ? 'v2' : 'legacy',
425
+ type: event.type,
426
+ hasText: Boolean(msg?.text),
427
+ space: space?.name,
428
+ });
429
+ return;
430
+ }
431
+ if (!this.onIncomingMessage) {
432
+ logger.warn('MESSAGE event received but no onIncomingMessage callback set — message dropped', {
433
+ format: isV2 ? 'v2' : 'legacy',
434
+ space: space?.name,
435
+ textSnippet: msg.text.slice(0, 80),
436
+ });
437
+ return;
438
+ }
439
+ const spaceName = space?.name || '';
440
+ const threadName = msg.thread?.name || '';
441
+ const senderName = msg.sender?.displayName || msg.sender?.name || '';
442
+ logger.info('Processing MESSAGE event', {
443
+ format: isV2 ? 'v2' : 'legacy',
444
+ space: spaceName,
445
+ sender: senderName,
446
+ textLength: msg.text.length,
447
+ thread: threadName ? threadName.slice(-20) : 'none',
448
+ });
449
+ const incomingMessage = {
450
+ platform: 'google-chat',
451
+ conversationId: spaceName,
452
+ channelId: spaceName,
453
+ userId: senderName,
454
+ text: msg.text,
455
+ threadId: threadName,
456
+ timestamp: msg.createTime || event.eventTime || new Date().toISOString(),
457
+ };
458
+ this.onIncomingMessage(incomingMessage);
459
+ }
460
+ /**
461
+ * Acknowledge processed messages to prevent redelivery.
462
+ *
463
+ * @param ackIds - Array of ack IDs from the pull response
464
+ */
465
+ async acknowledgeMessages(ackIds) {
466
+ if (!this.subscriptionName)
467
+ return;
468
+ const token = await this.getAccessToken();
469
+ const ackUrl = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_API_BASE}/${this.subscriptionName}:acknowledge`;
470
+ const resp = await fetch(ackUrl, {
471
+ method: 'POST',
472
+ headers: this.getAuthHeaders(token),
473
+ body: JSON.stringify({ ackIds }),
474
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
475
+ });
476
+ if (!resp.ok) {
477
+ const details = await resp.text();
478
+ throw new Error(`Pub/Sub acknowledge failed (${resp.status}): ${details}`);
479
+ }
480
+ }
481
+ // ===========================================================================
482
+ // Send Methods
483
+ // ===========================================================================
157
484
  /**
158
485
  * Send a message via incoming webhook URL.
159
486
  *
@@ -178,7 +505,7 @@ export class GoogleChatMessengerAdapter {
178
505
  method: 'POST',
179
506
  headers: { 'Content-Type': 'application/json; charset=UTF-8' },
180
507
  body: JSON.stringify(body),
181
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
508
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
182
509
  });
183
510
  if (!resp.ok) {
184
511
  const details = await resp.text();
@@ -188,59 +515,145 @@ export class GoogleChatMessengerAdapter {
188
515
  /**
189
516
  * Send a message via the Google Chat REST API using service account credentials.
190
517
  *
518
+ * When a threadId (thread name) is provided, the reply is posted to the same
519
+ * thread. This enables conversational thread tracking for Pub/Sub mode.
520
+ *
191
521
  * @param space - Space name (e.g., "spaces/AAAA...")
192
522
  * @param text - Message text
193
- * @param threadKey - Optional thread key for threaded replies
523
+ * @param threadName - Optional full thread name (e.g., "spaces/SPACE/threads/THREAD")
194
524
  */
195
- async sendViaApi(space, text, threadKey) {
196
- if (!this.serviceAccountKey) {
197
- throw new Error('Service account key not configured');
525
+ async sendViaApi(space, text, threadName) {
526
+ if (!this.serviceAccountKey && !this.adcCredentials) {
527
+ throw new Error('No credentials configured (service account key or ADC)');
198
528
  }
199
529
  if (!space || !space.startsWith('spaces/')) {
200
530
  throw new Error('Invalid Google Chat space name. Must start with "spaces/"');
201
531
  }
532
+ // Split long messages into chunks that fit within the API limit
533
+ const maxLen = GOOGLE_CHAT_PUBSUB_CONSTANTS.MAX_MESSAGE_LENGTH;
534
+ const chunks = text.length > maxLen
535
+ ? GoogleChatMessengerAdapter.splitMessage(text, maxLen - 96)
536
+ : [text];
202
537
  const token = await this.getAccessToken();
203
- const body = { text };
204
- if (threadKey) {
205
- body.thread = { name: `${space}/threads/${threadKey}` };
538
+ for (const chunk of chunks) {
539
+ const body = { text: chunk };
540
+ if (threadName) {
541
+ body.thread = { name: threadName };
542
+ }
543
+ let url = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_API_BASE}/${space}/messages`;
544
+ if (threadName) {
545
+ url += '?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD';
546
+ }
547
+ const resp = await fetch(url, {
548
+ method: 'POST',
549
+ headers: this.getAuthHeaders(token),
550
+ body: JSON.stringify(body),
551
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
552
+ });
553
+ if (!resp.ok) {
554
+ const details = await resp.text();
555
+ throw new Error(`Google Chat API send failed (${resp.status}): ${details}`);
556
+ }
206
557
  }
207
- const resp = await fetch(`https://chat.googleapis.com/v1/${space}/messages`, {
208
- method: 'POST',
209
- headers: {
210
- Authorization: `Bearer ${token}`,
211
- 'Content-Type': 'application/json',
212
- },
213
- body: JSON.stringify(body),
214
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
215
- });
216
- if (!resp.ok) {
217
- const details = await resp.text();
218
- throw new Error(`Google Chat API send failed (${resp.status}): ${details}`);
558
+ }
559
+ /**
560
+ * Split a message into chunks that fit within Google Chat's character limit.
561
+ *
562
+ * Splits on double-newline paragraph boundaries when possible. Falls back to
563
+ * single-newline, then hard truncation at maxLength.
564
+ *
565
+ * @param text - The full message text to split
566
+ * @param maxLength - Maximum characters per chunk (default 4000)
567
+ * @returns Array of text chunks, each within maxLength
568
+ */
569
+ static splitMessage(text, maxLength = 4000) {
570
+ if (text.length <= maxLength) {
571
+ return [text];
572
+ }
573
+ const chunks = [];
574
+ let remaining = text;
575
+ while (remaining.length > 0) {
576
+ if (remaining.length <= maxLength) {
577
+ chunks.push(remaining);
578
+ break;
579
+ }
580
+ // Try to split on a double-newline boundary
581
+ let splitIdx = remaining.lastIndexOf('\n\n', maxLength);
582
+ // Fall back to single newline
583
+ if (splitIdx <= 0) {
584
+ splitIdx = remaining.lastIndexOf('\n', maxLength);
585
+ }
586
+ // Hard cut if no newline found
587
+ if (splitIdx <= 0) {
588
+ splitIdx = maxLength;
589
+ }
590
+ chunks.push(remaining.slice(0, splitIdx));
591
+ remaining = remaining.slice(splitIdx).replace(/^\n+/, '');
219
592
  }
593
+ return chunks;
220
594
  }
595
+ // ===========================================================================
596
+ // Auth
597
+ // ===========================================================================
221
598
  /**
222
599
  * Get a valid access token, refreshing if needed.
223
600
  *
224
- * Uses a simplified JWT-based OAuth2 flow for service accounts.
601
+ * Uses JWT-based OAuth2 for service accounts, or refresh_token flow for ADC.
602
+ * The scope includes Pub/Sub when in pubsub mode.
225
603
  *
226
604
  * @returns Valid access token string
227
605
  */
228
606
  async getAccessToken() {
607
+ // When SA impersonation is active, return cached SA token if valid
608
+ if (this.serviceAccountEmail && this.saAccessToken && Date.now() < this.saTokenExpiresAt - 60_000) {
609
+ return this.saAccessToken;
610
+ }
229
611
  // Return cached token if still valid (with 60s buffer)
230
- if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
612
+ if (!this.serviceAccountEmail && this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
231
613
  return this.accessToken;
232
614
  }
615
+ // Deduplicate concurrent refresh requests (RL2)
616
+ if (this.pendingTokenRefresh) {
617
+ return this.pendingTokenRefresh;
618
+ }
619
+ this.pendingTokenRefresh = this.refreshAccessToken();
620
+ try {
621
+ return await this.pendingTokenRefresh;
622
+ }
623
+ finally {
624
+ this.pendingTokenRefresh = null;
625
+ }
626
+ }
627
+ /**
628
+ * Perform the actual token refresh.
629
+ *
630
+ * Dispatches to the appropriate flow based on authMode:
631
+ * - `service_account`: JWT-based OAuth2 flow with SA private key
632
+ * - `adc`: Refresh token flow using ADC credentials
633
+ *
634
+ * @returns Fresh access token string
635
+ */
636
+ async refreshAccessToken() {
637
+ if (this.authMode === 'adc') {
638
+ return this.refreshAccessTokenViaAdc();
639
+ }
640
+ return this.refreshAccessTokenViaJwt();
641
+ }
642
+ /**
643
+ * Refresh access token via JWT-based OAuth2 flow (service account).
644
+ *
645
+ * @returns Fresh access token string
646
+ */
647
+ async refreshAccessTokenViaJwt() {
233
648
  if (!this.serviceAccountKey) {
234
649
  throw new Error('Service account key not configured');
235
650
  }
236
- // For service account auth, we need to create a JWT and exchange it for an access token.
237
- // This is a simplified implementation — production use should use google-auth-library.
238
651
  const key = JSON.parse(this.serviceAccountKey);
239
652
  const now = Math.floor(Date.now() / 1000);
240
653
  const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
241
654
  const payload = Buffer.from(JSON.stringify({
242
655
  iss: key.client_email,
243
- scope: 'https://www.googleapis.com/auth/chat.bot',
656
+ scope: this.tokenScopes,
244
657
  aud: 'https://oauth2.googleapis.com/token',
245
658
  iat: now,
246
659
  exp: now + 3600,
@@ -255,7 +668,7 @@ export class GoogleChatMessengerAdapter {
255
668
  method: 'POST',
256
669
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
257
670
  body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
258
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
671
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
259
672
  });
260
673
  if (!resp.ok) {
261
674
  const details = await resp.text();
@@ -266,5 +679,212 @@ export class GoogleChatMessengerAdapter {
266
679
  this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
267
680
  return this.accessToken;
268
681
  }
682
+ /**
683
+ * Refresh access token via ADC (Application Default Credentials).
684
+ *
685
+ * Uses the refresh_token from the ADC JSON file to obtain a new access token
686
+ * from Google's OAuth2 token endpoint. This supports credentials created by
687
+ * `gcloud auth application-default login`.
688
+ *
689
+ * When `serviceAccountEmail` is configured, the ADC user token is used to
690
+ * impersonate the service account via the IAM Credentials API, producing a
691
+ * token with `chat.bot` and `pubsub` scopes that the Chat API accepts.
692
+ *
693
+ * @returns Fresh access token string (impersonated SA token if configured, else user token)
694
+ */
695
+ async refreshAccessTokenViaAdc() {
696
+ if (!this.adcCredentials) {
697
+ throw new Error('ADC credentials not loaded');
698
+ }
699
+ const { client_id, client_secret, refresh_token } = this.adcCredentials;
700
+ const body = new URLSearchParams({
701
+ grant_type: 'refresh_token',
702
+ client_id,
703
+ client_secret,
704
+ refresh_token,
705
+ });
706
+ const resp = await fetch('https://oauth2.googleapis.com/token', {
707
+ method: 'POST',
708
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
709
+ body: body.toString(),
710
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
711
+ });
712
+ if (!resp.ok) {
713
+ const details = await resp.text();
714
+ throw new Error(`ADC token refresh failed (${resp.status}): ${details}`);
715
+ }
716
+ const data = await resp.json();
717
+ this.accessToken = data.access_token;
718
+ this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
719
+ // If SA impersonation is configured, exchange the user token for an SA token
720
+ if (this.serviceAccountEmail) {
721
+ return this.impersonateServiceAccount(this.accessToken);
722
+ }
723
+ return this.accessToken;
724
+ }
725
+ /**
726
+ * Impersonate a service account using the IAM Credentials API.
727
+ *
728
+ * Uses the user's ADC access token to call `generateAccessToken` on the
729
+ * target service account, producing a token with `chat.bot` and `pubsub`
730
+ * scopes. The user must have the `Service Account Token Creator` IAM role.
731
+ *
732
+ * @param userToken - ADC user access token for authorization
733
+ * @returns Impersonated service account access token
734
+ * @throws Error if impersonation fails (e.g., missing IAM role)
735
+ */
736
+ async impersonateServiceAccount(userToken) {
737
+ // Return cached SA token if still valid (60s buffer)
738
+ if (this.saAccessToken && Date.now() < this.saTokenExpiresAt - 60_000) {
739
+ return this.saAccessToken;
740
+ }
741
+ const url = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${this.serviceAccountEmail}:generateAccessToken`;
742
+ const headers = {
743
+ Authorization: `Bearer ${userToken}`,
744
+ 'Content-Type': 'application/json',
745
+ };
746
+ if (this.projectId) {
747
+ headers['x-goog-user-project'] = this.projectId;
748
+ }
749
+ const reqBody = {
750
+ scope: [
751
+ GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE,
752
+ GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_SCOPE,
753
+ ],
754
+ lifetime: '3600s',
755
+ };
756
+ const resp = await fetch(url, {
757
+ method: 'POST',
758
+ headers,
759
+ body: JSON.stringify(reqBody),
760
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
761
+ });
762
+ if (!resp.ok) {
763
+ const details = await resp.text();
764
+ throw new Error(`SA impersonation failed (${resp.status}): ${details}`);
765
+ }
766
+ const result = await resp.json();
767
+ this.saAccessToken = result.accessToken;
768
+ this.saTokenExpiresAt = new Date(result.expireTime).getTime();
769
+ logger.info('Impersonated service account', {
770
+ serviceAccountEmail: this.serviceAccountEmail,
771
+ expiresAt: result.expireTime,
772
+ });
773
+ return this.saAccessToken;
774
+ }
775
+ // ===========================================================================
776
+ // Helpers
777
+ // ===========================================================================
778
+ /**
779
+ * Build auth headers for Google API requests.
780
+ *
781
+ * When using ADC auth mode, adds x-goog-user-project header to set the
782
+ * billing/quota project. Without this header, ADC requests fail with
783
+ * 403 PERMISSION_DENIED because Google cannot determine which project
784
+ * should be billed for the API usage.
785
+ *
786
+ * @param token - OAuth2 access token
787
+ * @returns Headers object with Authorization, Content-Type, and optional quota project
788
+ */
789
+ getAuthHeaders(token) {
790
+ const headers = {
791
+ Authorization: `Bearer ${token}`,
792
+ 'Content-Type': 'application/json',
793
+ };
794
+ if (this.authMode === 'adc' && this.projectId) {
795
+ headers['x-goog-user-project'] = this.projectId;
796
+ }
797
+ return headers;
798
+ }
799
+ /**
800
+ * Validate a service account key JSON string.
801
+ *
802
+ * @param key - JSON string of the service account key
803
+ * @throws Error if the key is invalid
804
+ */
805
+ validateServiceAccountKey(key) {
806
+ try {
807
+ const parsed = JSON.parse(key);
808
+ if (!parsed.client_email || !parsed.private_key) {
809
+ throw new Error('Service account key must contain client_email and private_key');
810
+ }
811
+ }
812
+ catch (err) {
813
+ if (err instanceof SyntaxError) {
814
+ throw new Error('Service account key must be valid JSON');
815
+ }
816
+ throw err;
817
+ }
818
+ }
819
+ /**
820
+ * Load ADC (Application Default Credentials) from the filesystem.
821
+ *
822
+ * Checks (in order):
823
+ * 1. `GOOGLE_APPLICATION_CREDENTIALS` environment variable (custom path)
824
+ * 2. Default gcloud ADC path: `~/.config/gcloud/application_default_credentials.json`
825
+ *
826
+ * @returns Parsed ADC credentials
827
+ * @throws Error if the ADC file is not found or invalid
828
+ */
829
+ async loadAdcCredentials() {
830
+ const { readFile } = await import('node:fs/promises');
831
+ const { join } = await import('node:path');
832
+ const { homedir } = await import('node:os');
833
+ const envPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
834
+ const defaultPath = join(homedir(), '.config', 'gcloud', 'application_default_credentials.json');
835
+ const credPath = envPath || defaultPath;
836
+ let raw;
837
+ try {
838
+ raw = await readFile(credPath, 'utf-8');
839
+ }
840
+ catch {
841
+ throw new Error(`ADC credentials file not found at ${credPath}. ` +
842
+ 'Run: gcloud auth application-default login --scopes=' +
843
+ 'https://www.googleapis.com/auth/chat.bot,' +
844
+ 'https://www.googleapis.com/auth/pubsub,' +
845
+ 'https://www.googleapis.com/auth/cloud-platform');
846
+ }
847
+ let parsed;
848
+ try {
849
+ parsed = JSON.parse(raw);
850
+ }
851
+ catch {
852
+ throw new Error('ADC credentials file is not valid JSON');
853
+ }
854
+ if (!parsed.client_id || !parsed.client_secret || !parsed.refresh_token) {
855
+ throw new Error('ADC credentials file must contain client_id, client_secret, and refresh_token. ' +
856
+ 'Ensure you ran gcloud auth application-default login with the correct scopes.');
857
+ }
858
+ return {
859
+ client_id: parsed.client_id,
860
+ client_secret: parsed.client_secret,
861
+ refresh_token: parsed.refresh_token,
862
+ type: parsed.type || 'authorized_user',
863
+ };
864
+ }
865
+ /**
866
+ * Reset all internal state to defaults.
867
+ */
868
+ resetState() {
869
+ this.stopPubSubPull();
870
+ this.webhookUrl = null;
871
+ this.serviceAccountKey = null;
872
+ this.authMode = 'service_account';
873
+ this.adcCredentials = null;
874
+ this.accessToken = null;
875
+ this.tokenExpiresAt = 0;
876
+ this.tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
877
+ this.pendingTokenRefresh = null;
878
+ this.subscriptionName = null;
879
+ this.projectId = null;
880
+ this.serviceAccountEmail = null;
881
+ this.saAccessToken = null;
882
+ this.saTokenExpiresAt = 0;
883
+ this.onIncomingMessage = null;
884
+ this.consecutiveFailures = 0;
885
+ this.pullPaused = false;
886
+ this.lastPullAt = null;
887
+ this.mode = 'none';
888
+ }
269
889
  }
270
890
  //# sourceMappingURL=google-chat-messenger.adapter.js.map