crewly 1.2.6 → 1.2.7

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 (29) hide show
  1. package/dist/backend/backend/src/constants.d.ts +24 -0
  2. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  3. package/dist/backend/backend/src/constants.js +24 -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 +64 -3
  8. package/dist/backend/backend/src/controllers/messaging/messenger.routes.js.map +1 -1
  9. package/dist/backend/backend/src/index.d.ts.map +1 -1
  10. package/dist/backend/backend/src/index.js +2 -0
  11. package/dist/backend/backend/src/index.js.map +1 -1
  12. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.d.ts +95 -22
  13. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.d.ts.map +1 -1
  14. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.js +284 -60
  15. package/dist/backend/backend/src/services/messaging/adapters/google-chat-messenger.adapter.js.map +1 -1
  16. package/dist/backend/backend/src/services/messaging/message-queue.service.js +1 -1
  17. package/dist/backend/backend/src/services/messaging/message-queue.service.js.map +1 -1
  18. package/dist/backend/backend/src/services/messaging/response-router.service.d.ts +8 -0
  19. package/dist/backend/backend/src/services/messaging/response-router.service.d.ts.map +1 -1
  20. package/dist/backend/backend/src/services/messaging/response-router.service.js +42 -0
  21. package/dist/backend/backend/src/services/messaging/response-router.service.js.map +1 -1
  22. package/dist/cli/backend/src/constants.d.ts +24 -0
  23. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  24. package/dist/cli/backend/src/constants.js +24 -0
  25. package/dist/cli/backend/src/constants.js.map +1 -1
  26. package/frontend/dist/assets/{index-3558d1a2.js → index-5cf8820f.js} +71 -71
  27. package/frontend/dist/assets/{index-4c4dcc31.css → index-dee3a3a9.css} +1 -1
  28. package/frontend/dist/index.html +2 -2
  29. package/package.json +1 -1
@@ -1,47 +1,74 @@
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
- import { MessengerAdapter, MessengerPlatform } from '../messenger-adapter.interface.js';
16
+ import { MessengerAdapter, MessengerPlatform, IncomingMessage } from '../messenger-adapter.interface.js';
11
17
  /**
12
- * Messenger adapter for Google Chat.
13
- *
14
- * Supports two modes:
15
- * 1. **Webhook mode** (simpler): uses an incoming webhook URL to post messages
16
- * 2. **Service account mode**: uses a service account key to call the Chat API
18
+ * Callback type for incoming messages from Pub/Sub pull.
19
+ */
20
+ export type GoogleChatIncomingCallback = (msg: IncomingMessage) => void;
21
+ /**
22
+ * Messenger adapter for Google Chat with Pub/Sub support.
17
23
  *
18
- * Webhook mode is used when `webhookUrl` is provided in config.
19
- * Service account mode is used when `serviceAccountKey` is provided.
24
+ * Supports webhook, service-account, and pubsub modes.
25
+ * Pub/Sub mode enables bidirectional communication with thread tracking.
20
26
  */
21
27
  export declare class GoogleChatMessengerAdapter implements MessengerAdapter {
22
28
  readonly platform: MessengerPlatform;
29
+ /** Current connection mode */
30
+ private mode;
23
31
  /** Webhook URL for posting messages (webhook mode) */
24
32
  private webhookUrl;
25
- /** Service account key JSON (service account mode) */
33
+ /** Service account key JSON string */
26
34
  private serviceAccountKey;
27
35
  /** Access token obtained from service account (cached) */
28
36
  private accessToken;
29
37
  /** Access token expiry timestamp */
30
38
  private tokenExpiresAt;
39
+ /** OAuth2 scopes for the current mode */
40
+ private tokenScopes;
41
+ /** Full Pub/Sub subscription resource name (e.g. projects/PROJECT/subscriptions/SUB) */
42
+ private subscriptionName;
43
+ /** GCP project ID (for Pub/Sub mode) */
44
+ private projectId;
45
+ /** Pub/Sub pull interval timer */
46
+ private pullIntervalTimer;
47
+ /** Callback for incoming messages */
48
+ private onIncomingMessage;
49
+ /** Consecutive pull failure count */
50
+ private consecutiveFailures;
51
+ /** Whether the pull loop is paused due to failures */
52
+ private pullPaused;
31
53
  /**
32
- * Initialize the adapter by validating credentials.
54
+ * Initialize the adapter with the provided credentials.
33
55
  *
34
- * @param config - Must contain either `webhookUrl` string or `serviceAccountKey` string
35
- * @throws Error if neither credential is provided or validation fails
56
+ * Detects mode based on provided config fields:
57
+ * - `webhookUrl` webhook mode
58
+ * - `serviceAccountKey` + `projectId` + `subscriptionName` → pubsub mode
59
+ * - `serviceAccountKey` alone → service-account mode
60
+ *
61
+ * @param config - Configuration object with credentials
62
+ * @throws Error if credentials are invalid or missing
36
63
  */
37
64
  initialize(config: Record<string, unknown>): Promise<void>;
38
65
  /**
39
66
  * Send a text message to a Google Chat space.
40
67
  *
41
68
  * In webhook mode, the `channel` parameter is ignored (webhook URL determines the space).
42
- * In service account mode, `channel` is the space name (e.g., "spaces/AAAA...").
69
+ * In service-account/pubsub mode, `channel` is the space name (e.g., "spaces/AAAA...").
43
70
  *
44
- * @param channel - Google Chat space name (used in service account mode)
71
+ * @param channel - Google Chat space name (used in service-account/pubsub mode)
45
72
  * @param text - Message content
46
73
  * @param options - Optional send options (threadId for threaded replies)
47
74
  * @throws Error if adapter is not initialized or send fails
@@ -52,7 +79,7 @@ export declare class GoogleChatMessengerAdapter implements MessengerAdapter {
52
79
  /**
53
80
  * Get the current connection status.
54
81
  *
55
- * @returns Status object with connected flag and platform identifier
82
+ * @returns Status object with connected flag, platform, and mode details
56
83
  */
57
84
  getStatus(): {
58
85
  connected: boolean;
@@ -60,9 +87,40 @@ export declare class GoogleChatMessengerAdapter implements MessengerAdapter {
60
87
  details?: Record<string, unknown>;
61
88
  };
62
89
  /**
63
- * Disconnect by clearing stored credentials.
90
+ * Disconnect by clearing stored credentials and stopping the pull loop.
64
91
  */
65
92
  disconnect(): Promise<void>;
93
+ /**
94
+ * Start the Pub/Sub pull loop that periodically fetches messages.
95
+ */
96
+ private startPubSubPull;
97
+ /**
98
+ * Stop the Pub/Sub pull loop.
99
+ */
100
+ private stopPubSubPull;
101
+ /**
102
+ * Pull messages from the Pub/Sub subscription, process them, and acknowledge.
103
+ *
104
+ * Each message is a base64-encoded Google Chat event JSON. Only MESSAGE-type
105
+ * events are forwarded to the incoming message callback. All messages are
106
+ * acknowledged regardless of type to prevent redelivery.
107
+ */
108
+ pullMessages(): Promise<void>;
109
+ /**
110
+ * Process a Google Chat event and forward MESSAGE events to the callback.
111
+ *
112
+ * Thread tracking: extracts the thread name from the event so that replies
113
+ * can be posted back to the same thread.
114
+ *
115
+ * @param event - Parsed Google Chat event payload
116
+ */
117
+ private processChatEvent;
118
+ /**
119
+ * Acknowledge processed messages to prevent redelivery.
120
+ *
121
+ * @param ackIds - Array of ack IDs from the pull response
122
+ */
123
+ private acknowledgeMessages;
66
124
  /**
67
125
  * Send a message via incoming webhook URL.
68
126
  *
@@ -73,18 +131,33 @@ export declare class GoogleChatMessengerAdapter implements MessengerAdapter {
73
131
  /**
74
132
  * Send a message via the Google Chat REST API using service account credentials.
75
133
  *
134
+ * When a threadId (thread name) is provided, the reply is posted to the same
135
+ * thread. This enables conversational thread tracking for Pub/Sub mode.
136
+ *
76
137
  * @param space - Space name (e.g., "spaces/AAAA...")
77
138
  * @param text - Message text
78
- * @param threadKey - Optional thread key for threaded replies
139
+ * @param threadName - Optional full thread name (e.g., "spaces/SPACE/threads/THREAD")
79
140
  */
80
141
  private sendViaApi;
81
142
  /**
82
143
  * Get a valid access token, refreshing if needed.
83
144
  *
84
- * Uses a simplified JWT-based OAuth2 flow for service accounts.
145
+ * Uses a JWT-based OAuth2 flow for service accounts.
146
+ * The scope includes Pub/Sub when in pubsub mode.
85
147
  *
86
148
  * @returns Valid access token string
87
149
  */
88
- private getAccessToken;
150
+ getAccessToken(): Promise<string>;
151
+ /**
152
+ * Validate a service account key JSON string.
153
+ *
154
+ * @param key - JSON string of the service account key
155
+ * @throws Error if the key is invalid
156
+ */
157
+ private validateServiceAccountKey;
158
+ /**
159
+ * Reset all internal state to defaults.
160
+ */
161
+ private resetState;
89
162
  }
90
163
  //# sourceMappingURL=google-chat-messenger.adapter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"google-chat-messenger.adapter.d.ts","sourceRoot":"","sources":["../../../../../../../backend/src/services/messaging/adapters/google-chat-messenger.adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AAKxF;;;;;;;;;GASG;AACH,qBAAa,0BAA2B,YAAW,gBAAgB;IACjE,QAAQ,CAAC,QAAQ,EAAE,iBAAiB,CAAiB;IAErD,sDAAsD;IACtD,OAAO,CAAC,UAAU,CAAuB;IAEzC,sDAAsD;IACtD,OAAO,CAAC,iBAAiB,CAAuB;IAEhD,0DAA0D;IAC1D,OAAO,CAAC,WAAW,CAAuB;IAE1C,oCAAoC;IACpC,OAAO,CAAC,cAAc,CAAK;IAE3B;;;;;OAKG;IACG,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAyChE;;;;;;;;;;OAUG;IACG,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAchG;;;;OAIG;IACH,SAAS,IAAI;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,QAAQ,EAAE,iBAAiB,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE;IAWnG;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAOjC;;;;;OAKG;YACW,cAAc;IA8B5B;;;;;;OAMG;YACW,UAAU;IAmCxB;;;;;;OAMG;YACW,cAAc;CAkD7B"}
1
+ {"version":3,"file":"google-chat-messenger.adapter.d.ts","sourceRoot":"","sources":["../../../../../../../backend/src/services/messaging/adapters/google-chat-messenger.adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAoCzG;;GAEG;AACH,MAAM,MAAM,0BAA0B,GAAG,CAAC,GAAG,EAAE,eAAe,KAAK,IAAI,CAAC;AAExE;;;;;GAKG;AACH,qBAAa,0BAA2B,YAAW,gBAAgB;IACjE,QAAQ,CAAC,QAAQ,EAAE,iBAAiB,CAAiB;IAErD,8BAA8B;IAC9B,OAAO,CAAC,IAAI,CAA0B;IAEtC,sDAAsD;IACtD,OAAO,CAAC,UAAU,CAAuB;IAEzC,sCAAsC;IACtC,OAAO,CAAC,iBAAiB,CAAuB;IAEhD,0DAA0D;IAC1D,OAAO,CAAC,WAAW,CAAuB;IAE1C,oCAAoC;IACpC,OAAO,CAAC,cAAc,CAAK;IAE3B,yCAAyC;IACzC,OAAO,CAAC,WAAW,CAAmD;IAEtE,wFAAwF;IACxF,OAAO,CAAC,gBAAgB,CAAuB;IAE/C,wCAAwC;IACxC,OAAO,CAAC,SAAS,CAAuB;IAExC,kCAAkC;IAClC,OAAO,CAAC,iBAAiB,CAA+C;IAExE,qCAAqC;IACrC,OAAO,CAAC,iBAAiB,CAA2C;IAEpE,qCAAqC;IACrC,OAAO,CAAC,mBAAmB,CAAK;IAEhC,sDAAsD;IACtD,OAAO,CAAC,UAAU,CAAS;IAE3B;;;;;;;;;;OAUG;IACG,UAAU,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAkDhE;;;;;;;;;;OAUG;IACG,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAchG;;;;OAIG;IACH,SAAS,IAAI;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,QAAQ,EAAE,iBAAiB,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE;IAiBnG;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IASjC;;OAEG;IACH,OAAO,CAAC,eAAe;IAmBvB;;OAEG;IACH,OAAO,CAAC,cAAc;IAOtB;;;;;;OAMG;IACG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAmDnC;;;;;;;OAOG;IACH,OAAO,CAAC,gBAAgB;IA6BxB;;;;OAIG;YACW,mBAAmB;IA0BjC;;;;;OAKG;YACW,cAAc;IA8B5B;;;;;;;;;OASG;YACW,UAAU;IA2CxB;;;;;;;OAOG;IACG,cAAc,IAAI,OAAO,CAAC,MAAM,CAAC;IAqDvC;;;;;OAKG;IACH,OAAO,CAAC,yBAAyB;IAcjC;;OAEG;IACH,OAAO,CAAC,UAAU;CAcnB"}
@@ -1,72 +1,99 @@
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
31
  /** Access token obtained from service account (cached) */
29
32
  accessToken = null;
30
33
  /** Access token expiry timestamp */
31
34
  tokenExpiresAt = 0;
35
+ /** OAuth2 scopes for the current mode */
36
+ tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
37
+ /** Full Pub/Sub subscription resource name (e.g. projects/PROJECT/subscriptions/SUB) */
38
+ subscriptionName = null;
39
+ /** GCP project ID (for Pub/Sub mode) */
40
+ projectId = null;
41
+ /** Pub/Sub pull interval timer */
42
+ pullIntervalTimer = null;
43
+ /** Callback for incoming messages */
44
+ onIncomingMessage = null;
45
+ /** Consecutive pull failure count */
46
+ consecutiveFailures = 0;
47
+ /** Whether the pull loop is paused due to failures */
48
+ pullPaused = false;
32
49
  /**
33
- * Initialize the adapter by validating credentials.
50
+ * Initialize the adapter with the provided credentials.
51
+ *
52
+ * Detects mode based on provided config fields:
53
+ * - `webhookUrl` → webhook mode
54
+ * - `serviceAccountKey` + `projectId` + `subscriptionName` → pubsub mode
55
+ * - `serviceAccountKey` alone → service-account mode
34
56
  *
35
- * @param config - Must contain either `webhookUrl` string or `serviceAccountKey` string
36
- * @throws Error if neither credential is provided or validation fails
57
+ * @param config - Configuration object with credentials
58
+ * @throws Error if credentials are invalid or missing
37
59
  */
38
60
  async initialize(config) {
39
61
  const webhookUrl = config.webhookUrl;
40
62
  const serviceAccountKey = config.serviceAccountKey;
63
+ const projectId = config.projectId;
64
+ const subscriptionName = config.subscriptionName;
65
+ const onIncomingMessage = config.onIncomingMessage;
66
+ // Webhook mode
41
67
  if (typeof webhookUrl === 'string' && webhookUrl) {
42
- // Webhook mode: validate the URL format
43
68
  if (!webhookUrl.startsWith('https://chat.googleapis.com/')) {
44
69
  throw new Error('Invalid Google Chat webhook URL. Must start with https://chat.googleapis.com/');
45
70
  }
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
71
+ this.resetState();
48
72
  this.webhookUrl = webhookUrl;
49
- this.serviceAccountKey = null;
50
- this.accessToken = null;
73
+ this.mode = 'webhook';
51
74
  return;
52
75
  }
76
+ // Pub/Sub mode or Service Account mode — both require a service account key
53
77
  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
- }
60
- }
61
- catch (err) {
62
- if (err instanceof SyntaxError) {
63
- throw new Error('Service account key must be valid JSON');
78
+ this.validateServiceAccountKey(serviceAccountKey);
79
+ this.resetState();
80
+ this.serviceAccountKey = serviceAccountKey;
81
+ // Pub/Sub mode: requires projectId + subscriptionName
82
+ if (typeof projectId === 'string' && projectId &&
83
+ typeof subscriptionName === 'string' && subscriptionName) {
84
+ this.projectId = projectId;
85
+ this.subscriptionName = `projects/${projectId}/subscriptions/${subscriptionName}`;
86
+ this.tokenScopes = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE} ${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_SCOPE}`;
87
+ this.mode = 'pubsub';
88
+ if (typeof onIncomingMessage === 'function') {
89
+ this.onIncomingMessage = onIncomingMessage;
64
90
  }
65
- throw err;
91
+ this.startPubSubPull();
92
+ return;
66
93
  }
67
- this.serviceAccountKey = serviceAccountKey;
68
- this.webhookUrl = null;
69
- this.accessToken = null;
94
+ // Service account mode (send-only)
95
+ this.mode = 'service-account';
96
+ this.tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
70
97
  return;
71
98
  }
72
99
  throw new Error('Google Chat requires either a webhookUrl or serviceAccountKey');
@@ -75,19 +102,19 @@ export class GoogleChatMessengerAdapter {
75
102
  * Send a text message to a Google Chat space.
76
103
  *
77
104
  * 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...").
105
+ * In service-account/pubsub mode, `channel` is the space name (e.g., "spaces/AAAA...").
79
106
  *
80
- * @param channel - Google Chat space name (used in service account mode)
107
+ * @param channel - Google Chat space name (used in service-account/pubsub mode)
81
108
  * @param text - Message content
82
109
  * @param options - Optional send options (threadId for threaded replies)
83
110
  * @throws Error if adapter is not initialized or send fails
84
111
  */
85
112
  async sendMessage(channel, text, options) {
86
- if (this.webhookUrl) {
113
+ if (this.mode === 'webhook' && this.webhookUrl) {
87
114
  await this.sendViaWebhook(text, options?.threadId);
88
115
  return;
89
116
  }
90
- if (this.serviceAccountKey) {
117
+ if ((this.mode === 'service-account' || this.mode === 'pubsub') && this.serviceAccountKey) {
91
118
  await this.sendViaApi(channel, text, options?.threadId);
92
119
  return;
93
120
  }
@@ -96,27 +123,173 @@ export class GoogleChatMessengerAdapter {
96
123
  /**
97
124
  * Get the current connection status.
98
125
  *
99
- * @returns Status object with connected flag and platform identifier
126
+ * @returns Status object with connected flag, platform, and mode details
100
127
  */
101
128
  getStatus() {
102
- const connected = Boolean(this.webhookUrl || this.serviceAccountKey);
103
129
  return {
104
- connected,
130
+ connected: this.mode !== 'none',
105
131
  platform: this.platform,
106
132
  details: {
107
- mode: this.webhookUrl ? 'webhook' : this.serviceAccountKey ? 'service-account' : 'none',
133
+ mode: this.mode,
134
+ ...(this.mode === 'pubsub' ? {
135
+ subscriptionName: this.subscriptionName,
136
+ projectId: this.projectId,
137
+ pullActive: Boolean(this.pullIntervalTimer) && !this.pullPaused,
138
+ pullPaused: this.pullPaused,
139
+ consecutiveFailures: this.consecutiveFailures,
140
+ } : {}),
108
141
  },
109
142
  };
110
143
  }
111
144
  /**
112
- * Disconnect by clearing stored credentials.
145
+ * Disconnect by clearing stored credentials and stopping the pull loop.
113
146
  */
114
147
  async disconnect() {
115
- this.webhookUrl = null;
116
- this.serviceAccountKey = null;
117
- this.accessToken = null;
118
- this.tokenExpiresAt = 0;
148
+ this.stopPubSubPull();
149
+ this.resetState();
150
+ }
151
+ // ===========================================================================
152
+ // Pub/Sub Pull Loop
153
+ // ===========================================================================
154
+ /**
155
+ * Start the Pub/Sub pull loop that periodically fetches messages.
156
+ */
157
+ startPubSubPull() {
158
+ this.stopPubSubPull();
159
+ this.consecutiveFailures = 0;
160
+ this.pullPaused = false;
161
+ this.pullIntervalTimer = setInterval(async () => {
162
+ if (this.pullPaused)
163
+ return;
164
+ try {
165
+ await this.pullMessages();
166
+ this.consecutiveFailures = 0;
167
+ }
168
+ catch {
169
+ this.consecutiveFailures++;
170
+ if (this.consecutiveFailures >= GOOGLE_CHAT_PUBSUB_CONSTANTS.MAX_CONSECUTIVE_FAILURES) {
171
+ this.pullPaused = true;
172
+ }
173
+ }
174
+ }, GOOGLE_CHAT_PUBSUB_CONSTANTS.PULL_INTERVAL_MS);
175
+ }
176
+ /**
177
+ * Stop the Pub/Sub pull loop.
178
+ */
179
+ stopPubSubPull() {
180
+ if (this.pullIntervalTimer) {
181
+ clearInterval(this.pullIntervalTimer);
182
+ this.pullIntervalTimer = null;
183
+ }
184
+ }
185
+ /**
186
+ * Pull messages from the Pub/Sub subscription, process them, and acknowledge.
187
+ *
188
+ * Each message is a base64-encoded Google Chat event JSON. Only MESSAGE-type
189
+ * events are forwarded to the incoming message callback. All messages are
190
+ * acknowledged regardless of type to prevent redelivery.
191
+ */
192
+ async pullMessages() {
193
+ if (!this.subscriptionName) {
194
+ throw new Error('Pub/Sub subscription not configured');
195
+ }
196
+ const token = await this.getAccessToken();
197
+ const pullUrl = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_API_BASE}/${this.subscriptionName}:pull`;
198
+ const resp = await fetch(pullUrl, {
199
+ method: 'POST',
200
+ headers: {
201
+ Authorization: `Bearer ${token}`,
202
+ 'Content-Type': 'application/json',
203
+ },
204
+ body: JSON.stringify({ maxMessages: GOOGLE_CHAT_PUBSUB_CONSTANTS.MAX_MESSAGES_PER_PULL }),
205
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
206
+ });
207
+ if (!resp.ok) {
208
+ const details = await resp.text();
209
+ throw new Error(`Pub/Sub pull failed (${resp.status}): ${details}`);
210
+ }
211
+ const data = await resp.json();
212
+ if (!data.receivedMessages || data.receivedMessages.length === 0) {
213
+ return;
214
+ }
215
+ const ackIds = [];
216
+ for (const received of data.receivedMessages) {
217
+ ackIds.push(received.ackId);
218
+ if (!received.message.data)
219
+ continue;
220
+ try {
221
+ const decoded = Buffer.from(received.message.data, 'base64').toString('utf-8');
222
+ const event = JSON.parse(decoded);
223
+ this.processChatEvent(event);
224
+ }
225
+ catch {
226
+ // Skip malformed messages — they will still be acked
227
+ }
228
+ }
229
+ // Acknowledge all messages (even non-MESSAGE types) to prevent redelivery
230
+ if (ackIds.length > 0) {
231
+ await this.acknowledgeMessages(ackIds);
232
+ }
119
233
  }
234
+ /**
235
+ * Process a Google Chat event and forward MESSAGE events to the callback.
236
+ *
237
+ * Thread tracking: extracts the thread name from the event so that replies
238
+ * can be posted back to the same thread.
239
+ *
240
+ * @param event - Parsed Google Chat event payload
241
+ */
242
+ processChatEvent(event) {
243
+ // Only process MESSAGE events (ignore ADDED_TO_SPACE, REMOVED_FROM_SPACE, etc.)
244
+ if (event.type !== 'MESSAGE' || !event.message?.text) {
245
+ return;
246
+ }
247
+ if (!this.onIncomingMessage) {
248
+ return;
249
+ }
250
+ const spaceName = event.space?.name || '';
251
+ const threadName = event.message.thread?.name || '';
252
+ const senderName = event.message.sender?.displayName || event.message.sender?.name || '';
253
+ const incomingMessage = {
254
+ platform: 'google-chat',
255
+ conversationId: spaceName,
256
+ channelId: spaceName,
257
+ userId: senderName,
258
+ text: event.message.text,
259
+ // Thread name is the full resource path (e.g. spaces/SPACE/threads/THREAD)
260
+ // This is used to reply in the same thread via the Chat API
261
+ threadId: threadName,
262
+ timestamp: event.message.createTime || event.eventTime || new Date().toISOString(),
263
+ };
264
+ this.onIncomingMessage(incomingMessage);
265
+ }
266
+ /**
267
+ * Acknowledge processed messages to prevent redelivery.
268
+ *
269
+ * @param ackIds - Array of ack IDs from the pull response
270
+ */
271
+ async acknowledgeMessages(ackIds) {
272
+ if (!this.subscriptionName)
273
+ return;
274
+ const token = await this.getAccessToken();
275
+ const ackUrl = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.PUBSUB_API_BASE}/${this.subscriptionName}:acknowledge`;
276
+ const resp = await fetch(ackUrl, {
277
+ method: 'POST',
278
+ headers: {
279
+ Authorization: `Bearer ${token}`,
280
+ 'Content-Type': 'application/json',
281
+ },
282
+ body: JSON.stringify({ ackIds }),
283
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
284
+ });
285
+ if (!resp.ok) {
286
+ const details = await resp.text();
287
+ throw new Error(`Pub/Sub acknowledge failed (${resp.status}): ${details}`);
288
+ }
289
+ }
290
+ // ===========================================================================
291
+ // Send Methods
292
+ // ===========================================================================
120
293
  /**
121
294
  * Send a message via incoming webhook URL.
122
295
  *
@@ -141,7 +314,7 @@ export class GoogleChatMessengerAdapter {
141
314
  method: 'POST',
142
315
  headers: { 'Content-Type': 'application/json; charset=UTF-8' },
143
316
  body: JSON.stringify(body),
144
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
317
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
145
318
  });
146
319
  if (!resp.ok) {
147
320
  const details = await resp.text();
@@ -151,11 +324,14 @@ export class GoogleChatMessengerAdapter {
151
324
  /**
152
325
  * Send a message via the Google Chat REST API using service account credentials.
153
326
  *
327
+ * When a threadId (thread name) is provided, the reply is posted to the same
328
+ * thread. This enables conversational thread tracking for Pub/Sub mode.
329
+ *
154
330
  * @param space - Space name (e.g., "spaces/AAAA...")
155
331
  * @param text - Message text
156
- * @param threadKey - Optional thread key for threaded replies
332
+ * @param threadName - Optional full thread name (e.g., "spaces/SPACE/threads/THREAD")
157
333
  */
158
- async sendViaApi(space, text, threadKey) {
334
+ async sendViaApi(space, text, threadName) {
159
335
  if (!this.serviceAccountKey) {
160
336
  throw new Error('Service account key not configured');
161
337
  }
@@ -164,27 +340,37 @@ export class GoogleChatMessengerAdapter {
164
340
  }
165
341
  const token = await this.getAccessToken();
166
342
  const body = { text };
167
- if (threadKey) {
168
- body.thread = { name: `${space}/threads/${threadKey}` };
343
+ if (threadName) {
344
+ // Use the full thread resource name for API-based threading
345
+ body.thread = { name: threadName };
346
+ }
347
+ // Build URL with messageReplyOption to enable thread replies
348
+ let url = `${GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_API_BASE}/${space}/messages`;
349
+ if (threadName) {
350
+ url += '?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD';
169
351
  }
170
- const resp = await fetch(`https://chat.googleapis.com/v1/${space}/messages`, {
352
+ const resp = await fetch(url, {
171
353
  method: 'POST',
172
354
  headers: {
173
355
  Authorization: `Bearer ${token}`,
174
356
  'Content-Type': 'application/json',
175
357
  },
176
358
  body: JSON.stringify(body),
177
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
359
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
178
360
  });
179
361
  if (!resp.ok) {
180
362
  const details = await resp.text();
181
363
  throw new Error(`Google Chat API send failed (${resp.status}): ${details}`);
182
364
  }
183
365
  }
366
+ // ===========================================================================
367
+ // Auth
368
+ // ===========================================================================
184
369
  /**
185
370
  * Get a valid access token, refreshing if needed.
186
371
  *
187
- * Uses a simplified JWT-based OAuth2 flow for service accounts.
372
+ * Uses a JWT-based OAuth2 flow for service accounts.
373
+ * The scope includes Pub/Sub when in pubsub mode.
188
374
  *
189
375
  * @returns Valid access token string
190
376
  */
@@ -196,14 +382,12 @@ export class GoogleChatMessengerAdapter {
196
382
  if (!this.serviceAccountKey) {
197
383
  throw new Error('Service account key not configured');
198
384
  }
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
385
  const key = JSON.parse(this.serviceAccountKey);
202
386
  const now = Math.floor(Date.now() / 1000);
203
387
  const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
204
388
  const payload = Buffer.from(JSON.stringify({
205
389
  iss: key.client_email,
206
- scope: 'https://www.googleapis.com/auth/chat.bot',
390
+ scope: this.tokenScopes,
207
391
  aud: 'https://oauth2.googleapis.com/token',
208
392
  iat: now,
209
393
  exp: now + 3600,
@@ -218,7 +402,7 @@ export class GoogleChatMessengerAdapter {
218
402
  method: 'POST',
219
403
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
220
404
  body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
221
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
405
+ signal: AbortSignal.timeout(GOOGLE_CHAT_PUBSUB_CONSTANTS.FETCH_TIMEOUT_MS),
222
406
  });
223
407
  if (!resp.ok) {
224
408
  const details = await resp.text();
@@ -229,5 +413,45 @@ export class GoogleChatMessengerAdapter {
229
413
  this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
230
414
  return this.accessToken;
231
415
  }
416
+ // ===========================================================================
417
+ // Helpers
418
+ // ===========================================================================
419
+ /**
420
+ * Validate a service account key JSON string.
421
+ *
422
+ * @param key - JSON string of the service account key
423
+ * @throws Error if the key is invalid
424
+ */
425
+ validateServiceAccountKey(key) {
426
+ try {
427
+ const parsed = JSON.parse(key);
428
+ if (!parsed.client_email || !parsed.private_key) {
429
+ throw new Error('Service account key must contain client_email and private_key');
430
+ }
431
+ }
432
+ catch (err) {
433
+ if (err instanceof SyntaxError) {
434
+ throw new Error('Service account key must be valid JSON');
435
+ }
436
+ throw err;
437
+ }
438
+ }
439
+ /**
440
+ * Reset all internal state to defaults.
441
+ */
442
+ resetState() {
443
+ this.stopPubSubPull();
444
+ this.webhookUrl = null;
445
+ this.serviceAccountKey = null;
446
+ this.accessToken = null;
447
+ this.tokenExpiresAt = 0;
448
+ this.tokenScopes = GOOGLE_CHAT_PUBSUB_CONSTANTS.CHAT_SCOPE;
449
+ this.subscriptionName = null;
450
+ this.projectId = null;
451
+ this.onIncomingMessage = null;
452
+ this.consecutiveFailures = 0;
453
+ this.pullPaused = false;
454
+ this.mode = 'none';
455
+ }
232
456
  }
233
457
  //# sourceMappingURL=google-chat-messenger.adapter.js.map