crewly 1.2.6 → 1.2.8

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 (89) hide show
  1. package/dist/backend/backend/src/constants.d.ts +33 -0
  2. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  3. package/dist/backend/backend/src/constants.js +33 -0
  4. package/dist/backend/backend/src/constants.js.map +1 -1
  5. package/dist/backend/backend/src/controllers/messaging/messenger.routes.d.ts +7 -0
  6. package/dist/backend/backend/src/controllers/messaging/messenger.routes.d.ts.map +1 -1
  7. package/dist/backend/backend/src/controllers/messaging/messenger.routes.js +67 -3
  8. package/dist/backend/backend/src/controllers/messaging/messenger.routes.js.map +1 -1
  9. package/dist/backend/backend/src/controllers/settings/settings.controller.d.ts.map +1 -1
  10. package/dist/backend/backend/src/controllers/settings/settings.controller.js +97 -2
  11. package/dist/backend/backend/src/controllers/settings/settings.controller.js.map +1 -1
  12. package/dist/backend/backend/src/controllers/team/team.controller.d.ts.map +1 -1
  13. package/dist/backend/backend/src/controllers/team/team.controller.js +149 -1
  14. package/dist/backend/backend/src/controllers/team/team.controller.js.map +1 -1
  15. package/dist/backend/backend/src/index.d.ts.map +1 -1
  16. package/dist/backend/backend/src/index.js +2 -0
  17. package/dist/backend/backend/src/index.js.map +1 -1
  18. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  19. package/dist/backend/backend/src/services/agent/agent-registration.service.js +111 -82
  20. package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
  21. package/dist/backend/backend/src/services/agent/auditor-scheduler.service.d.ts.map +1 -1
  22. package/dist/backend/backend/src/services/agent/auditor-scheduler.service.js +14 -3
  23. package/dist/backend/backend/src/services/agent/auditor-scheduler.service.js.map +1 -1
  24. package/dist/backend/backend/src/services/agent/crewly-agent/model-manager.d.ts +16 -2
  25. package/dist/backend/backend/src/services/agent/crewly-agent/model-manager.d.ts.map +1 -1
  26. package/dist/backend/backend/src/services/agent/crewly-agent/model-manager.js +52 -5
  27. package/dist/backend/backend/src/services/agent/crewly-agent/model-manager.js.map +1 -1
  28. package/dist/backend/backend/src/services/agent/gemini-runtime.service.d.ts.map +1 -1
  29. package/dist/backend/backend/src/services/agent/gemini-runtime.service.js +22 -9
  30. package/dist/backend/backend/src/services/agent/gemini-runtime.service.js.map +1 -1
  31. package/dist/backend/backend/src/services/event-bus/event-bus.service.d.ts.map +1 -1
  32. package/dist/backend/backend/src/services/event-bus/event-bus.service.js +4 -3
  33. package/dist/backend/backend/src/services/event-bus/event-bus.service.js.map +1 -1
  34. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.d.ts +152 -23
  35. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.d.ts.map +1 -1
  36. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.js +438 -63
  37. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.js.map +1 -1
  38. package/dist/backend/backend/src/services/messaging/message-queue.service.js +1 -1
  39. package/dist/backend/backend/src/services/messaging/message-queue.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/messaging/response-router.service.d.ts +20 -0
  41. package/dist/backend/backend/src/services/messaging/response-router.service.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/messaging/response-router.service.js +40 -13
  43. package/dist/backend/backend/src/services/messaging/response-router.service.js.map +1 -1
  44. package/dist/backend/backend/src/services/orchestrator/orchestrator-restart.service.d.ts.map +1 -1
  45. package/dist/backend/backend/src/services/orchestrator/orchestrator-restart.service.js +12 -3
  46. package/dist/backend/backend/src/services/orchestrator/orchestrator-restart.service.js.map +1 -1
  47. package/dist/backend/backend/src/services/settings/settings.service.d.ts +9 -1
  48. package/dist/backend/backend/src/services/settings/settings.service.d.ts.map +1 -1
  49. package/dist/backend/backend/src/services/settings/settings.service.js +12 -1
  50. package/dist/backend/backend/src/services/settings/settings.service.js.map +1 -1
  51. package/dist/backend/backend/src/services/slack/notify-reconciliation.service.d.ts.map +1 -1
  52. package/dist/backend/backend/src/services/slack/notify-reconciliation.service.js +13 -0
  53. package/dist/backend/backend/src/services/slack/notify-reconciliation.service.js.map +1 -1
  54. package/dist/backend/backend/src/types/chat.types.d.ts +1 -1
  55. package/dist/backend/backend/src/types/chat.types.d.ts.map +1 -1
  56. package/dist/backend/backend/src/types/chat.types.js +1 -1
  57. package/dist/backend/backend/src/types/chat.types.js.map +1 -1
  58. package/dist/backend/backend/src/types/settings.types.d.ts +93 -0
  59. package/dist/backend/backend/src/types/settings.types.d.ts.map +1 -1
  60. package/dist/backend/backend/src/types/settings.types.js +131 -0
  61. package/dist/backend/backend/src/types/settings.types.js.map +1 -1
  62. package/dist/backend/backend/src/types/skill.types.js +2 -2
  63. package/dist/backend/backend/src/types/skill.types.js.map +1 -1
  64. package/dist/backend/backend/src/utils/format-error.d.ts +8 -0
  65. package/dist/backend/backend/src/utils/format-error.d.ts.map +1 -0
  66. package/dist/backend/backend/src/utils/format-error.js +10 -0
  67. package/dist/backend/backend/src/utils/format-error.js.map +1 -0
  68. package/dist/backend/backend/src/websocket/terminal.gateway.d.ts.map +1 -1
  69. package/dist/backend/backend/src/websocket/terminal.gateway.js +8 -6
  70. package/dist/backend/backend/src/websocket/terminal.gateway.js.map +1 -1
  71. package/dist/cli/backend/src/constants.d.ts +33 -0
  72. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  73. package/dist/cli/backend/src/constants.js +33 -0
  74. package/dist/cli/backend/src/constants.js.map +1 -1
  75. package/dist/cli/backend/src/types/chat.types.d.ts +1 -1
  76. package/dist/cli/backend/src/types/chat.types.d.ts.map +1 -1
  77. package/dist/cli/backend/src/types/chat.types.js +1 -1
  78. package/dist/cli/backend/src/types/chat.types.js.map +1 -1
  79. package/dist/cli/backend/src/types/settings.types.d.ts +93 -0
  80. package/dist/cli/backend/src/types/settings.types.d.ts.map +1 -1
  81. package/dist/cli/backend/src/types/settings.types.js +131 -0
  82. package/dist/cli/backend/src/types/settings.types.js.map +1 -1
  83. package/dist/cli/backend/src/types/skill.types.js +2 -2
  84. package/dist/cli/backend/src/types/skill.types.js.map +1 -1
  85. package/frontend/dist/assets/index-58509b6a.js +4919 -0
  86. package/frontend/dist/assets/{index-4c4dcc31.css → index-ddb38eb0.css} +1 -1
  87. package/frontend/dist/index.html +2 -2
  88. package/package.json +1 -1
  89. package/frontend/dist/assets/index-3558d1a2.js +0 -4919
@@ -1,93 +1,153 @@
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';
12
17
  /**
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
18
+ * Messenger adapter for Google Chat with Pub/Sub support.
18
19
  *
19
- * Webhook mode is used when `webhookUrl` is provided in config.
20
- * Service account mode is used when `serviceAccountKey` is provided.
20
+ * Supports webhook, service-account, and pubsub modes.
21
+ * Pub/Sub mode enables bidirectional communication with thread tracking.
21
22
  */
22
23
  export class GoogleChatMessengerAdapter {
23
24
  platform = 'google-chat';
25
+ /** Current connection mode */
26
+ mode = 'none';
24
27
  /** Webhook URL for posting messages (webhook mode) */
25
28
  webhookUrl = null;
26
- /** Service account key JSON (service account mode) */
29
+ /** Service account key JSON string */
27
30
  serviceAccountKey = null;
28
- /** Access token obtained from service account (cached) */
31
+ /** Authentication mode: service_account (JWT) or adc (refresh token) */
32
+ authMode = 'service_account';
33
+ /** ADC credentials (parsed from application_default_credentials.json) */
34
+ adcCredentials = null;
35
+ /** Access token obtained from service account or ADC (cached) */
29
36
  accessToken = null;
30
37
  /** Access token expiry timestamp */
31
38
  tokenExpiresAt = 0;
39
+ /** OAuth2 scopes for the current mode */
40
+ tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
41
+ /** Pending token refresh promise to deduplicate concurrent requests (RL2) */
42
+ pendingTokenRefresh = null;
43
+ /** Full Pub/Sub subscription resource name (e.g. projects/PROJECT/subscriptions/SUB) */
44
+ subscriptionName = null;
45
+ /** GCP project ID (for Pub/Sub mode) */
46
+ projectId = null;
47
+ /** Pub/Sub pull interval timer */
48
+ pullIntervalTimer = null;
49
+ /** Callback for incoming messages */
50
+ onIncomingMessage = null;
51
+ /** Consecutive pull failure count */
52
+ consecutiveFailures = 0;
53
+ /** Whether the pull loop is paused due to failures */
54
+ pullPaused = false;
32
55
  /**
33
- * Initialize the adapter by validating credentials.
56
+ * Initialize the adapter with the provided credentials.
57
+ *
58
+ * Detects mode based on provided config fields:
59
+ * - `webhookUrl` → webhook mode
60
+ * - `serviceAccountKey` + `projectId` + `subscriptionName` → pubsub mode
61
+ * - `serviceAccountKey` alone → service-account mode
34
62
  *
35
- * @param config - Must contain either `webhookUrl` string or `serviceAccountKey` string
36
- * @throws Error if neither credential is provided or validation fails
63
+ * @param config - Configuration object with credentials
64
+ * @throws Error if credentials are invalid or missing
37
65
  */
38
66
  async initialize(config) {
39
67
  const webhookUrl = config.webhookUrl;
40
68
  const serviceAccountKey = config.serviceAccountKey;
69
+ const projectId = config.projectId;
70
+ const subscriptionName = config.subscriptionName;
71
+ const onIncomingMessage = config.onIncomingMessage;
72
+ const authMode = (config.authMode === 'adc' ? 'adc' : 'service_account');
73
+ // Webhook mode
41
74
  if (typeof webhookUrl === 'string' && webhookUrl) {
42
- // Webhook mode: validate the URL format
43
75
  if (!webhookUrl.startsWith('https://chat.googleapis.com/')) {
44
76
  throw new Error('Invalid Google Chat webhook URL. Must start with https://chat.googleapis.com/');
45
77
  }
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
78
+ this.resetState();
48
79
  this.webhookUrl = webhookUrl;
49
- this.serviceAccountKey = null;
50
- this.accessToken = null;
80
+ this.mode = 'webhook';
51
81
  return;
52
82
  }
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');
83
+ // ADC mode use Application Default Credentials (no SA key needed)
84
+ if (authMode === 'adc') {
85
+ const adcCreds = await this.loadAdcCredentials();
86
+ this.resetState();
87
+ this.authMode = 'adc';
88
+ this.adcCredentials = adcCreds;
89
+ // Pub/Sub mode: requires projectId + subscriptionName
90
+ if (typeof projectId === 'string' && projectId &&
91
+ typeof subscriptionName === 'string' && subscriptionName) {
92
+ this.projectId = projectId;
93
+ this.subscriptionName = `projects/${projectId}/subscriptions/${subscriptionName}`;
94
+ this.tokenScopes = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE} ${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_SCOPE}`;
95
+ this.mode = 'pubsub';
96
+ if (typeof onIncomingMessage === 'function') {
97
+ this.onIncomingMessage = onIncomingMessage;
59
98
  }
99
+ this.startPubSubPull();
100
+ return;
60
101
  }
61
- catch (err) {
62
- if (err instanceof SyntaxError) {
63
- throw new Error('Service account key must be valid JSON');
102
+ // Service account mode with ADC auth (send-only)
103
+ this.mode = 'service-account';
104
+ this.tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
105
+ return;
106
+ }
107
+ // Service Account mode — requires a service account key
108
+ if (typeof serviceAccountKey === 'string' && serviceAccountKey) {
109
+ this.validateServiceAccountKey(serviceAccountKey);
110
+ this.resetState();
111
+ this.authMode = 'service_account';
112
+ this.serviceAccountKey = serviceAccountKey;
113
+ // Pub/Sub mode: requires projectId + subscriptionName
114
+ if (typeof projectId === 'string' && projectId &&
115
+ typeof subscriptionName === 'string' && subscriptionName) {
116
+ this.projectId = projectId;
117
+ this.subscriptionName = `projects/${projectId}/subscriptions/${subscriptionName}`;
118
+ this.tokenScopes = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE} ${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_SCOPE}`;
119
+ this.mode = 'pubsub';
120
+ if (typeof onIncomingMessage === 'function') {
121
+ this.onIncomingMessage = onIncomingMessage;
64
122
  }
65
- throw err;
123
+ this.startPubSubPull();
124
+ return;
66
125
  }
67
- this.serviceAccountKey = serviceAccountKey;
68
- this.webhookUrl = null;
69
- this.accessToken = null;
126
+ // Service account mode (send-only)
127
+ this.mode = 'service-account';
128
+ this.tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
70
129
  return;
71
130
  }
72
- throw new Error('Google Chat requires either a webhookUrl or serviceAccountKey');
131
+ throw new Error('Google Chat requires either a webhookUrl, serviceAccountKey, or authMode: adc');
73
132
  }
74
133
  /**
75
134
  * Send a text message to a Google Chat space.
76
135
  *
77
136
  * 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...").
137
+ * In service-account/pubsub mode, `channel` is the space name (e.g., "spaces/AAAA...").
79
138
  *
80
- * @param channel - Google Chat space name (used in service account mode)
139
+ * @param channel - Google Chat space name (used in service-account/pubsub mode)
81
140
  * @param text - Message content
82
141
  * @param options - Optional send options (threadId for threaded replies)
83
142
  * @throws Error if adapter is not initialized or send fails
84
143
  */
85
144
  async sendMessage(channel, text, options) {
86
- if (this.webhookUrl) {
145
+ if (this.mode === 'webhook' && this.webhookUrl) {
87
146
  await this.sendViaWebhook(text, options?.threadId);
88
147
  return;
89
148
  }
90
- if (this.serviceAccountKey) {
149
+ if ((this.mode === 'service-account' || this.mode === 'pubsub') &&
150
+ (this.serviceAccountKey || this.adcCredentials)) {
91
151
  await this.sendViaApi(channel, text, options?.threadId);
92
152
  return;
93
153
  }
@@ -96,27 +156,174 @@ export class GoogleChatMessengerAdapter {
96
156
  /**
97
157
  * Get the current connection status.
98
158
  *
99
- * @returns Status object with connected flag and platform identifier
159
+ * @returns Status object with connected flag, platform, and mode details
100
160
  */
101
161
  getStatus() {
102
- const connected = Boolean(this.webhookUrl || this.serviceAccountKey);
103
162
  return {
104
- connected,
163
+ connected: this.mode !== 'none',
105
164
  platform: this.platform,
106
165
  details: {
107
- mode: this.webhookUrl ? 'webhook' : this.serviceAccountKey ? 'service-account' : 'none',
166
+ mode: this.mode,
167
+ authMode: this.authMode,
168
+ ...(this.mode === 'pubsub' ? {
169
+ subscriptionName: this.subscriptionName,
170
+ projectId: this.projectId,
171
+ pullActive: Boolean(this.pullIntervalTimer) && !this.pullPaused,
172
+ pullPaused: this.pullPaused,
173
+ consecutiveFailures: this.consecutiveFailures,
174
+ } : {}),
108
175
  },
109
176
  };
110
177
  }
111
178
  /**
112
- * Disconnect by clearing stored credentials.
179
+ * Disconnect by clearing stored credentials and stopping the pull loop.
113
180
  */
114
181
  async disconnect() {
115
- this.webhookUrl = null;
116
- this.serviceAccountKey = null;
117
- this.accessToken = null;
118
- this.tokenExpiresAt = 0;
182
+ this.stopPubSubPull();
183
+ this.resetState();
184
+ }
185
+ // ===========================================================================
186
+ // Pub/Sub Pull Loop
187
+ // ===========================================================================
188
+ /**
189
+ * Start the Pub/Sub pull loop that periodically fetches messages.
190
+ */
191
+ startPubSubPull() {
192
+ this.stopPubSubPull();
193
+ this.consecutiveFailures = 0;
194
+ this.pullPaused = false;
195
+ this.pullIntervalTimer = setInterval(async () => {
196
+ if (this.pullPaused)
197
+ return;
198
+ try {
199
+ await this.pullMessages();
200
+ this.consecutiveFailures = 0;
201
+ }
202
+ catch {
203
+ this.consecutiveFailures++;
204
+ if (this.consecutiveFailures >= GOOGLE_CHAT_PUBSUB_CONSTANTS.MAX_CONSECUTIVE_FAILURES) {
205
+ this.pullPaused = true;
206
+ }
207
+ }
208
+ }, GOOGLE_CHAT_PUBSUB_CONSTANTS.PULL_INTERVAL_MS);
209
+ }
210
+ /**
211
+ * Stop the Pub/Sub pull loop.
212
+ */
213
+ stopPubSubPull() {
214
+ if (this.pullIntervalTimer) {
215
+ clearInterval(this.pullIntervalTimer);
216
+ this.pullIntervalTimer = null;
217
+ }
218
+ }
219
+ /**
220
+ * Pull messages from the Pub/Sub subscription, process them, and acknowledge.
221
+ *
222
+ * Each message is a base64-encoded Google Chat event JSON. Only MESSAGE-type
223
+ * events are forwarded to the incoming message callback. All messages are
224
+ * acknowledged regardless of type to prevent redelivery.
225
+ */
226
+ async pullMessages() {
227
+ if (!this.subscriptionName) {
228
+ throw new Error('Pub/Sub subscription not configured');
229
+ }
230
+ const token = await this.getAccessToken();
231
+ const pullUrl = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_API_BASE}/${this.subscriptionName}:pull`;
232
+ const resp = await fetch(pullUrl, {
233
+ method: 'POST',
234
+ headers: {
235
+ Authorization: `Bearer ${token}`,
236
+ 'Content-Type': 'application/json',
237
+ },
238
+ body: JSON.stringify({ maxMessages: GOOGLE_CHAT_PUBSUB_CONSTANTS.MAX_MESSAGES_PER_PULL }),
239
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
240
+ });
241
+ if (!resp.ok) {
242
+ const details = await resp.text();
243
+ throw new Error(`Pub/Sub pull failed (${resp.status}): ${details}`);
244
+ }
245
+ const data = await resp.json();
246
+ if (!data.receivedMessages || data.receivedMessages.length === 0) {
247
+ return;
248
+ }
249
+ const ackIds = [];
250
+ for (const received of data.receivedMessages) {
251
+ ackIds.push(received.ackId);
252
+ if (!received.message.data)
253
+ continue;
254
+ try {
255
+ const decoded = Buffer.from(received.message.data, 'base64').toString('utf-8');
256
+ const event = JSON.parse(decoded);
257
+ this.processChatEvent(event);
258
+ }
259
+ catch {
260
+ // Skip malformed messages — they will still be acked
261
+ }
262
+ }
263
+ // Acknowledge all messages (even non-MESSAGE types) to prevent redelivery
264
+ if (ackIds.length > 0) {
265
+ await this.acknowledgeMessages(ackIds);
266
+ }
267
+ }
268
+ /**
269
+ * Process a Google Chat event and forward MESSAGE events to the callback.
270
+ *
271
+ * Thread tracking: extracts the thread name from the event so that replies
272
+ * can be posted back to the same thread.
273
+ *
274
+ * @param event - Parsed Google Chat event payload
275
+ */
276
+ processChatEvent(event) {
277
+ // Only process MESSAGE events (ignore ADDED_TO_SPACE, REMOVED_FROM_SPACE, etc.)
278
+ if (event.type !== 'MESSAGE' || !event.message?.text) {
279
+ return;
280
+ }
281
+ if (!this.onIncomingMessage) {
282
+ return;
283
+ }
284
+ const spaceName = event.space?.name || '';
285
+ const threadName = event.message.thread?.name || '';
286
+ const senderName = event.message.sender?.displayName || event.message.sender?.name || '';
287
+ const incomingMessage = {
288
+ platform: 'google-chat',
289
+ conversationId: spaceName,
290
+ channelId: spaceName,
291
+ userId: senderName,
292
+ text: event.message.text,
293
+ // Thread name is the full resource path (e.g. spaces/SPACE/threads/THREAD)
294
+ // This is used to reply in the same thread via the Chat API
295
+ threadId: threadName,
296
+ timestamp: event.message.createTime || event.eventTime || new Date().toISOString(),
297
+ };
298
+ this.onIncomingMessage(incomingMessage);
299
+ }
300
+ /**
301
+ * Acknowledge processed messages to prevent redelivery.
302
+ *
303
+ * @param ackIds - Array of ack IDs from the pull response
304
+ */
305
+ async acknowledgeMessages(ackIds) {
306
+ if (!this.subscriptionName)
307
+ return;
308
+ const token = await this.getAccessToken();
309
+ const ackUrl = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_API_BASE}/${this.subscriptionName}:acknowledge`;
310
+ const resp = await fetch(ackUrl, {
311
+ method: 'POST',
312
+ headers: {
313
+ Authorization: `Bearer ${token}`,
314
+ 'Content-Type': 'application/json',
315
+ },
316
+ body: JSON.stringify({ ackIds }),
317
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
318
+ });
319
+ if (!resp.ok) {
320
+ const details = await resp.text();
321
+ throw new Error(`Pub/Sub acknowledge failed (${resp.status}): ${details}`);
322
+ }
119
323
  }
324
+ // ===========================================================================
325
+ // Send Methods
326
+ // ===========================================================================
120
327
  /**
121
328
  * Send a message via incoming webhook URL.
122
329
  *
@@ -141,7 +348,7 @@ export class GoogleChatMessengerAdapter {
141
348
  method: 'POST',
142
349
  headers: { 'Content-Type': 'application/json; charset=UTF-8' },
143
350
  body: JSON.stringify(body),
144
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
351
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
145
352
  });
146
353
  if (!resp.ok) {
147
354
  const details = await resp.text();
@@ -151,40 +358,53 @@ export class GoogleChatMessengerAdapter {
151
358
  /**
152
359
  * Send a message via the Google Chat REST API using service account credentials.
153
360
  *
361
+ * When a threadId (thread name) is provided, the reply is posted to the same
362
+ * thread. This enables conversational thread tracking for Pub/Sub mode.
363
+ *
154
364
  * @param space - Space name (e.g., "spaces/AAAA...")
155
365
  * @param text - Message text
156
- * @param threadKey - Optional thread key for threaded replies
366
+ * @param threadName - Optional full thread name (e.g., "spaces/SPACE/threads/THREAD")
157
367
  */
158
- async sendViaApi(space, text, threadKey) {
159
- if (!this.serviceAccountKey) {
160
- throw new Error('Service account key not configured');
368
+ async sendViaApi(space, text, threadName) {
369
+ if (!this.serviceAccountKey && !this.adcCredentials) {
370
+ throw new Error('No credentials configured (service account key or ADC)');
161
371
  }
162
372
  if (!space || !space.startsWith('spaces/')) {
163
373
  throw new Error('Invalid Google Chat space name. Must start with "spaces/"');
164
374
  }
165
375
  const token = await this.getAccessToken();
166
376
  const body = { text };
167
- if (threadKey) {
168
- body.thread = { name: `${space}/threads/${threadKey}` };
377
+ if (threadName) {
378
+ // Use the full thread resource name for API-based threading
379
+ body.thread = { name: threadName };
169
380
  }
170
- const resp = await fetch(`https://chat.googleapis.com/v1/${space}/messages`, {
381
+ // Build URL with messageReplyOption to enable thread replies
382
+ let url = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_API_BASE}/${space}/messages`;
383
+ if (threadName) {
384
+ url += '?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD';
385
+ }
386
+ const resp = await fetch(url, {
171
387
  method: 'POST',
172
388
  headers: {
173
389
  Authorization: `Bearer ${token}`,
174
390
  'Content-Type': 'application/json',
175
391
  },
176
392
  body: JSON.stringify(body),
177
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
393
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
178
394
  });
179
395
  if (!resp.ok) {
180
396
  const details = await resp.text();
181
397
  throw new Error(`Google Chat API send failed (${resp.status}): ${details}`);
182
398
  }
183
399
  }
400
+ // ===========================================================================
401
+ // Auth
402
+ // ===========================================================================
184
403
  /**
185
404
  * Get a valid access token, refreshing if needed.
186
405
  *
187
- * Uses a simplified JWT-based OAuth2 flow for service accounts.
406
+ * Uses JWT-based OAuth2 for service accounts, or refresh_token flow for ADC.
407
+ * The scope includes Pub/Sub when in pubsub mode.
188
408
  *
189
409
  * @returns Valid access token string
190
410
  */
@@ -193,17 +413,48 @@ export class GoogleChatMessengerAdapter {
193
413
  if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) {
194
414
  return this.accessToken;
195
415
  }
416
+ // Deduplicate concurrent refresh requests (RL2)
417
+ if (this.pendingTokenRefresh) {
418
+ return this.pendingTokenRefresh;
419
+ }
420
+ this.pendingTokenRefresh = this.refreshAccessToken();
421
+ try {
422
+ return await this.pendingTokenRefresh;
423
+ }
424
+ finally {
425
+ this.pendingTokenRefresh = null;
426
+ }
427
+ }
428
+ /**
429
+ * Perform the actual token refresh.
430
+ *
431
+ * Dispatches to the appropriate flow based on authMode:
432
+ * - `service_account`: JWT-based OAuth2 flow with SA private key
433
+ * - `adc`: Refresh token flow using ADC credentials
434
+ *
435
+ * @returns Fresh access token string
436
+ */
437
+ async refreshAccessToken() {
438
+ if (this.authMode === 'adc') {
439
+ return this.refreshAccessTokenViaAdc();
440
+ }
441
+ return this.refreshAccessTokenViaJwt();
442
+ }
443
+ /**
444
+ * Refresh access token via JWT-based OAuth2 flow (service account).
445
+ *
446
+ * @returns Fresh access token string
447
+ */
448
+ async refreshAccessTokenViaJwt() {
196
449
  if (!this.serviceAccountKey) {
197
450
  throw new Error('Service account key not configured');
198
451
  }
199
- // For service account auth, we need to create a JWT and exchange it for an access token.
200
- // This is a simplified implementation — production use should use google-auth-library.
201
452
  const key = JSON.parse(this.serviceAccountKey);
202
453
  const now = Math.floor(Date.now() / 1000);
203
454
  const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
204
455
  const payload = Buffer.from(JSON.stringify({
205
456
  iss: key.client_email,
206
- scope: 'https://www.googleapis.com/auth/chat.bot',
457
+ scope: this.tokenScopes,
207
458
  aud: 'https://oauth2.googleapis.com/token',
208
459
  iat: now,
209
460
  exp: now + 3600,
@@ -218,7 +469,7 @@ export class GoogleChatMessengerAdapter {
218
469
  method: 'POST',
219
470
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
220
471
  body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
221
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
472
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
222
473
  });
223
474
  if (!resp.ok) {
224
475
  const details = await resp.text();
@@ -229,5 +480,129 @@ export class GoogleChatMessengerAdapter {
229
480
  this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
230
481
  return this.accessToken;
231
482
  }
483
+ /**
484
+ * Refresh access token via ADC (Application Default Credentials).
485
+ *
486
+ * Uses the refresh_token from the ADC JSON file to obtain a new access token
487
+ * from Google's OAuth2 token endpoint. This supports credentials created by
488
+ * `gcloud auth application-default login`.
489
+ *
490
+ * @returns Fresh access token string
491
+ */
492
+ async refreshAccessTokenViaAdc() {
493
+ if (!this.adcCredentials) {
494
+ throw new Error('ADC credentials not loaded');
495
+ }
496
+ const { client_id, client_secret, refresh_token } = this.adcCredentials;
497
+ const body = new URLSearchParams({
498
+ grant_type: 'refresh_token',
499
+ client_id,
500
+ client_secret,
501
+ refresh_token,
502
+ });
503
+ const resp = await fetch('https://oauth2.googleapis.com/token', {
504
+ method: 'POST',
505
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
506
+ body: body.toString(),
507
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
508
+ });
509
+ if (!resp.ok) {
510
+ const details = await resp.text();
511
+ throw new Error(`ADC token refresh failed (${resp.status}): ${details}`);
512
+ }
513
+ const data = await resp.json();
514
+ this.accessToken = data.access_token;
515
+ this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
516
+ return this.accessToken;
517
+ }
518
+ // ===========================================================================
519
+ // Helpers
520
+ // ===========================================================================
521
+ /**
522
+ * Validate a service account key JSON string.
523
+ *
524
+ * @param key - JSON string of the service account key
525
+ * @throws Error if the key is invalid
526
+ */
527
+ validateServiceAccountKey(key) {
528
+ try {
529
+ const parsed = JSON.parse(key);
530
+ if (!parsed.client_email || !parsed.private_key) {
531
+ throw new Error('Service account key must contain client_email and private_key');
532
+ }
533
+ }
534
+ catch (err) {
535
+ if (err instanceof SyntaxError) {
536
+ throw new Error('Service account key must be valid JSON');
537
+ }
538
+ throw err;
539
+ }
540
+ }
541
+ /**
542
+ * Load ADC (Application Default Credentials) from the filesystem.
543
+ *
544
+ * Checks (in order):
545
+ * 1. `GOOGLE_APPLICATION_CREDENTIALS` environment variable (custom path)
546
+ * 2. Default gcloud ADC path: `~/.config/gcloud/application_default_credentials.json`
547
+ *
548
+ * @returns Parsed ADC credentials
549
+ * @throws Error if the ADC file is not found or invalid
550
+ */
551
+ async loadAdcCredentials() {
552
+ const { readFile } = await import('node:fs/promises');
553
+ const { join } = await import('node:path');
554
+ const { homedir } = await import('node:os');
555
+ const envPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
556
+ const defaultPath = join(homedir(), '.config', 'gcloud', 'application_default_credentials.json');
557
+ const credPath = envPath || defaultPath;
558
+ let raw;
559
+ try {
560
+ raw = await readFile(credPath, 'utf-8');
561
+ }
562
+ catch {
563
+ throw new Error(`ADC credentials file not found at ${credPath}. ` +
564
+ 'Run: gcloud auth application-default login --scopes=' +
565
+ 'https://www.googleapis.com/auth/chat.bot,' +
566
+ 'https://www.googleapis.com/auth/pubsub,' +
567
+ 'https://www.googleapis.com/auth/cloud-platform');
568
+ }
569
+ let parsed;
570
+ try {
571
+ parsed = JSON.parse(raw);
572
+ }
573
+ catch {
574
+ throw new Error('ADC credentials file is not valid JSON');
575
+ }
576
+ if (!parsed.client_id || !parsed.client_secret || !parsed.refresh_token) {
577
+ throw new Error('ADC credentials file must contain client_id, client_secret, and refresh_token. ' +
578
+ 'Ensure you ran gcloud auth application-default login with the correct scopes.');
579
+ }
580
+ return {
581
+ client_id: parsed.client_id,
582
+ client_secret: parsed.client_secret,
583
+ refresh_token: parsed.refresh_token,
584
+ type: parsed.type || 'authorized_user',
585
+ };
586
+ }
587
+ /**
588
+ * Reset all internal state to defaults.
589
+ */
590
+ resetState() {
591
+ this.stopPubSubPull();
592
+ this.webhookUrl = null;
593
+ this.serviceAccountKey = null;
594
+ this.authMode = 'service_account';
595
+ this.adcCredentials = null;
596
+ this.accessToken = null;
597
+ this.tokenExpiresAt = 0;
598
+ this.tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
599
+ this.pendingTokenRefresh = null;
600
+ this.subscriptionName = null;
601
+ this.projectId = null;
602
+ this.onIncomingMessage = null;
603
+ this.consecutiveFailures = 0;
604
+ this.pullPaused = false;
605
+ this.mode = 'none';
606
+ }
232
607
  }
233
608
  //# sourceMappingURL=google-chat-messenger.adapter.js.map