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.
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +7 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator/orchestrator.controller.d.ts +16 -0
- package/dist/backend/backend/src/controllers/orchestrator/orchestrator.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator/orchestrator.controller.js +124 -13
- package/dist/backend/backend/src/controllers/orchestrator/orchestrator.controller.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-auth.middleware.js +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-auth.middleware.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/relay-client.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/relay-client.service.js +5 -3
- package/dist/backend/backend/src/services/cloud/relay-client.service.js.map +1 -1
- package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.d.ts +212 -24
- package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.d.ts.map +1 -1
- package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.js +705 -85
- package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.js.map +1 -1
- package/dist/backend/backend/src/services/session/session-handoff.service.d.ts +35 -1
- package/dist/backend/backend/src/services/session/session-handoff.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/session-handoff.service.js +113 -4
- package/dist/backend/backend/src/services/session/session-handoff.service.js.map +1 -1
- package/frontend/dist/assets/index-c35cdbc5.js +5213 -0
- package/frontend/dist/assets/{index-60a9e4ea.css → index-f1dc9f80.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-1d23cce8.js +0 -4919
package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.js
CHANGED
|
@@ -1,93 +1,197 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Google Chat Messenger Adapter
|
|
3
3
|
*
|
|
4
|
-
* Messenger adapter for Google Chat
|
|
5
|
-
*
|
|
6
|
-
* the Chat API
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
31
|
+
/** Service account key JSON string */
|
|
27
32
|
serviceAccountKey = null;
|
|
28
|
-
/**
|
|
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
|
|
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 -
|
|
36
|
-
* @throws Error if
|
|
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
|
-
|
|
47
|
-
// so we just validate the URL format and store it
|
|
97
|
+
this.resetState();
|
|
48
98
|
this.webhookUrl = webhookUrl;
|
|
49
|
-
this.
|
|
50
|
-
this.accessToken = null;
|
|
99
|
+
this.mode = 'webhook';
|
|
51
100
|
return;
|
|
52
101
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
116
|
-
this.
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
523
|
+
* @param threadName - Optional full thread name (e.g., "spaces/SPACE/threads/THREAD")
|
|
194
524
|
*/
|
|
195
|
-
async sendViaApi(space, text,
|
|
196
|
-
if (!this.serviceAccountKey) {
|
|
197
|
-
throw new Error('
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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:
|
|
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
|