@alfe.ai/openclaw-google-chat 0.0.29 → 0.0.30

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/plugin2.cjs CHANGED
@@ -9,28 +9,34 @@ let node_os = require("node:os");
9
9
  /**
10
10
  * State persistence — saves poller state to ~/.alfe/state/google-chat-poller.json.
11
11
  *
12
- * Written every 30s (debounced). Read on startup. Defaults to now-5min if
13
- * no state file exists.
12
+ * Written atomically every 30s when dirty and flushed on shutdown. Read and
13
+ * runtime-validated on startup; missing or invalid state starts fresh.
14
14
  */
15
- const STATE_DIR = (0, node_path.join)((0, node_os.homedir)(), ".alfe", "state");
16
- const STATE_FILE = (0, node_path.join)(STATE_DIR, "google-chat-poller.json");
15
+ const STATE_FILE = (0, node_path.join)((0, node_path.join)((0, node_os.homedir)(), ".alfe", "state"), "google-chat-poller.json");
17
16
  const SAVE_INTERVAL_MS = 3e4;
18
17
  var StateManager = class {
19
18
  state = { spaces: {} };
20
19
  dirty = false;
20
+ revision = 0;
21
+ flushPromise = null;
21
22
  saveTimer = null;
22
23
  log;
23
- constructor(log) {
24
+ stateFile;
25
+ constructor(log, stateFile = STATE_FILE) {
24
26
  this.log = log;
27
+ this.stateFile = stateFile;
25
28
  }
26
29
  /** Load state from disk. Returns default state if file doesn't exist. */
27
30
  async load() {
28
31
  try {
29
- const raw = await (0, node_fs_promises.readFile)(STATE_FILE, "utf-8");
30
- this.state = JSON.parse(raw);
32
+ const raw = await (0, node_fs_promises.readFile)(this.stateFile, "utf-8");
33
+ const parsed = JSON.parse(raw);
34
+ if (!isPersistedState(parsed)) throw new Error("invalid state shape");
35
+ this.state = parsed;
31
36
  this.log.info(`Loaded state: ${String(Object.keys(this.state.spaces).length)} known spaces`);
32
- } catch {
33
- this.log.debug("No existing state file — starting fresh");
37
+ } catch (err) {
38
+ if (isMissingFileError(err)) this.log.debug("No existing state file — starting fresh");
39
+ else this.log.warn(`Ignoring unreadable Google Chat state: ${err instanceof Error ? err.message : String(err)}`);
34
40
  this.state = { spaces: {} };
35
41
  }
36
42
  return this.state;
@@ -50,7 +56,8 @@ var StateManager = class {
50
56
  clearInterval(this.saveTimer);
51
57
  this.saveTimer = null;
52
58
  }
53
- if (this.dirty) await this.flush();
59
+ while (this.dirty || this.flushPromise) if (this.flushPromise) await this.flushPromise;
60
+ else await this.flush();
54
61
  }
55
62
  /** Get the last-seen timestamp for a space, or undefined if not yet tracked. */
56
63
  getLastSeenTimestamp(spaceName) {
@@ -65,7 +72,7 @@ var StateManager = class {
65
72
  setAgentIdentity(userId, email) {
66
73
  this.state.agentUserId = userId;
67
74
  this.state.agentEmail = email;
68
- this.dirty = true;
75
+ this.markDirty();
69
76
  }
70
77
  /** Get the agent's userId for a space (if previously resolved). */
71
78
  getAgentUserId(spaceName) {
@@ -86,27 +93,75 @@ var StateManager = class {
86
93
  ...existing,
87
94
  ...data
88
95
  };
89
- this.dirty = true;
96
+ this.markDirty();
90
97
  }
91
98
  /** Remove a space from tracked state. */
92
99
  removeSpace(spaceName) {
93
100
  delete this.state.spaces[spaceName];
94
- this.dirty = true;
101
+ this.markDirty();
95
102
  }
96
103
  /** Write state to disk immediately. */
97
- async flush() {
98
- try {
99
- await (0, node_fs_promises.mkdir)(STATE_DIR, { recursive: true });
100
- await (0, node_fs_promises.writeFile)(STATE_FILE, JSON.stringify(this.state, null, 2));
101
- this.dirty = false;
102
- } catch (err) {
103
- this.log.error(`Failed to write state: ${err instanceof Error ? err.message : String(err)}`);
104
- }
104
+ flush() {
105
+ if (this.flushPromise) return this.flushPromise;
106
+ if (!this.dirty) return Promise.resolve();
107
+ const revision = this.revision;
108
+ const contents = JSON.stringify(this.state, null, 2);
109
+ const stateDir = (0, node_path.dirname)(this.stateFile);
110
+ const temporaryFile = `${this.stateFile}.${String(process.pid)}.tmp`;
111
+ this.flushPromise = (async () => {
112
+ try {
113
+ await (0, node_fs_promises.mkdir)(stateDir, {
114
+ recursive: true,
115
+ mode: 448
116
+ });
117
+ await (0, node_fs_promises.writeFile)(temporaryFile, contents, { mode: 384 });
118
+ await (0, node_fs_promises.rename)(temporaryFile, this.stateFile);
119
+ if (this.revision === revision) this.dirty = false;
120
+ } catch (err) {
121
+ await (0, node_fs_promises.rm)(temporaryFile, { force: true }).catch(() => void 0);
122
+ this.log.error(`Failed to write state: ${err instanceof Error ? err.message : String(err)}`);
123
+ throw err;
124
+ } finally {
125
+ this.flushPromise = null;
126
+ }
127
+ })();
128
+ return this.flushPromise;
129
+ }
130
+ markDirty() {
131
+ this.revision += 1;
132
+ this.dirty = true;
105
133
  }
106
134
  };
135
+ function isMissingFileError(err) {
136
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
137
+ }
138
+ function isPersistedState(value) {
139
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
140
+ const record = value;
141
+ if (typeof record.spaces !== "object" || record.spaces === null || Array.isArray(record.spaces)) return false;
142
+ if (record.agentUserId !== void 0 && typeof record.agentUserId !== "string") return false;
143
+ if (record.agentEmail !== void 0 && typeof record.agentEmail !== "string") return false;
144
+ for (const entry of Object.values(record.spaces)) {
145
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return false;
146
+ const space = entry;
147
+ if (typeof space.lastSeenTimestamp !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(space.lastSeenTimestamp) || !Number.isFinite(Date.parse(space.lastSeenTimestamp)) || typeof space.agentUserId !== "string" || typeof space.peerUserId !== "string") return false;
148
+ }
149
+ return true;
150
+ }
107
151
  //#endregion
108
152
  //#region src/gchat-api.ts
109
153
  const CHAT_API = "https://chat.googleapis.com/v1";
154
+ const REQUEST_TIMEOUT_MS = 2e4;
155
+ const MAX_RESPONSE_BYTES = 1048576;
156
+ const MAX_PAGES = 100;
157
+ const MAX_ITEMS = 1e4;
158
+ var GoogleChatApiError = class extends Error {
159
+ constructor(message, status) {
160
+ super(message);
161
+ this.status = status;
162
+ this.name = "GoogleChatApiError";
163
+ }
164
+ };
110
165
  async function request(token, path, options) {
111
166
  const url = `${CHAT_API}${path}`;
112
167
  const headers = new Headers(options?.headers);
@@ -114,24 +169,158 @@ async function request(token, path, options) {
114
169
  headers.set("Content-Type", "application/json");
115
170
  const res = await fetch(url, {
116
171
  ...options,
117
- headers
172
+ headers,
173
+ signal: options?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
118
174
  });
119
175
  if (!res.ok) {
120
- const body = await res.text().catch(() => "");
121
- throw Object.assign(/* @__PURE__ */ new Error(`Google Chat API error ${String(res.status)}: ${body}`), { status: res.status });
176
+ await res.body?.cancel().catch(() => void 0);
177
+ throw new GoogleChatApiError(`Google Chat API error ${String(res.status)}`, res.status);
178
+ }
179
+ const raw = await readBoundedBody(res);
180
+ try {
181
+ return JSON.parse(raw);
182
+ } catch {
183
+ throw new GoogleChatApiError("Google Chat API returned invalid JSON");
122
184
  }
123
- return await res.json();
185
+ }
186
+ async function readBoundedBody(response) {
187
+ const declaredLength = response.headers.get("content-length");
188
+ if (declaredLength) {
189
+ const length = Number(declaredLength);
190
+ if (!Number.isFinite(length) || length < 0 || length > MAX_RESPONSE_BYTES) {
191
+ await response.body?.cancel().catch(() => void 0);
192
+ throw new GoogleChatApiError("Google Chat API response exceeded the size limit");
193
+ }
194
+ }
195
+ if (!response.body) return "";
196
+ const reader = response.body.getReader();
197
+ const decoder = new TextDecoder();
198
+ let total = 0;
199
+ let result = "";
200
+ let chunk = await reader.read();
201
+ while (!chunk.done) {
202
+ total += chunk.value.byteLength;
203
+ if (total > MAX_RESPONSE_BYTES) {
204
+ await reader.cancel().catch(() => void 0);
205
+ throw new GoogleChatApiError("Google Chat API response exceeded the size limit");
206
+ }
207
+ result += decoder.decode(chunk.value, { stream: true });
208
+ chunk = await reader.read();
209
+ }
210
+ return result + decoder.decode();
211
+ }
212
+ function asRecord(value, label) {
213
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new GoogleChatApiError(`Google Chat API returned an invalid ${label}`);
214
+ return value;
215
+ }
216
+ function readOptionalString(record, key) {
217
+ const value = record[key];
218
+ if (value === void 0) return void 0;
219
+ if (typeof value !== "string") throw new GoogleChatApiError(`Google Chat API returned an invalid ${key}`);
220
+ return value;
221
+ }
222
+ function readPageToken(record) {
223
+ const token = readOptionalString(record, "nextPageToken");
224
+ return token === "" ? void 0 : token;
225
+ }
226
+ function readArray(record, key, parse) {
227
+ const value = record[key];
228
+ if (value === void 0) return [];
229
+ if (!Array.isArray(value) || value.length > MAX_ITEMS) throw new GoogleChatApiError(`Google Chat API returned an invalid ${key}`);
230
+ return value.map(parse);
231
+ }
232
+ function parseUser(value) {
233
+ const user = asRecord(value, "user");
234
+ const name = readOptionalString(user, "name");
235
+ const type = readOptionalString(user, "type");
236
+ const displayName = readOptionalString(user, "displayName");
237
+ if (!name || !type) throw new GoogleChatApiError("Google Chat API returned an invalid user");
238
+ try {
239
+ userResource(name);
240
+ } catch {
241
+ throw new GoogleChatApiError("Google Chat API returned an invalid user");
242
+ }
243
+ return {
244
+ name,
245
+ type,
246
+ ...displayName !== void 0 ? { displayName } : {}
247
+ };
248
+ }
249
+ function parseSpace(value) {
250
+ const space = asRecord(value, "space");
251
+ const name = readOptionalString(space, "name");
252
+ const type = readOptionalString(space, "type");
253
+ const spaceType = readOptionalString(space, "spaceType");
254
+ const singleUserBotDm = space.singleUserBotDm;
255
+ if (!name || !spaceType || type !== void 0 && !type || singleUserBotDm !== void 0 && typeof singleUserBotDm !== "boolean") throw new GoogleChatApiError("Google Chat API returned an invalid space");
256
+ try {
257
+ spaceResource(name);
258
+ } catch {
259
+ throw new GoogleChatApiError("Google Chat API returned an invalid space");
260
+ }
261
+ return {
262
+ name,
263
+ displayName: readOptionalString(space, "displayName") ?? "",
264
+ type: type ?? spaceType,
265
+ spaceType,
266
+ singleUserBotDm: singleUserBotDm ?? false
267
+ };
268
+ }
269
+ function parseMessage(value) {
270
+ const message = asRecord(value, "message");
271
+ const name = readOptionalString(message, "name");
272
+ const createTime = readOptionalString(message, "createTime");
273
+ if (!name || !/^spaces\/[A-Za-z0-9_-]+\/messages\/[A-Za-z0-9_.-]+$/.test(name) || !createTime || !isRfc3339(createTime) || message.sender === void 0) throw new GoogleChatApiError("Google Chat API returned an invalid message");
274
+ return {
275
+ name,
276
+ sender: parseUser(message.sender),
277
+ createTime,
278
+ text: readOptionalString(message, "text") ?? ""
279
+ };
280
+ }
281
+ function isRfc3339(value) {
282
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value) && Number.isFinite(Date.parse(value));
283
+ }
284
+ function parseMembership(value) {
285
+ const membership = asRecord(value, "membership");
286
+ const name = readOptionalString(membership, "name");
287
+ const role = readOptionalString(membership, "role");
288
+ const state = readOptionalString(membership, "state");
289
+ if (!name || !/^spaces\/[A-Za-z0-9_-]+\/members\/[A-Za-z0-9_.-]+$/.test(name) || !role || !state || membership.member === void 0) throw new GoogleChatApiError("Google Chat API returned an invalid membership");
290
+ return {
291
+ name,
292
+ member: parseUser(membership.member),
293
+ role,
294
+ state
295
+ };
296
+ }
297
+ function spaceResource(value) {
298
+ const match = /^spaces\/([A-Za-z0-9_-]+)$/.exec(value);
299
+ if (!match) throw new Error("Invalid Google Chat space resource name");
300
+ return `spaces/${encodeURIComponent(match[1])}`;
301
+ }
302
+ function userResource(value) {
303
+ const match = /^users\/([A-Za-z0-9._+@-]+)$/.exec(value);
304
+ if (!match) throw new Error("Invalid Google Chat user resource name");
305
+ return `users/${encodeURIComponent(match[1])}`;
124
306
  }
125
307
  /** List all spaces the user is a member of. */
126
308
  async function listSpaces(token, log) {
127
309
  const allSpaces = [];
128
310
  let pageToken;
311
+ const seenPageTokens = /* @__PURE__ */ new Set();
312
+ let pageCount = 0;
129
313
  do {
314
+ pageCount += 1;
315
+ if (pageCount > MAX_PAGES) throw new GoogleChatApiError("Google Chat spaces pagination exceeded the page limit");
130
316
  const params = new URLSearchParams({ pageSize: "100" });
131
317
  if (pageToken) params.set("pageToken", pageToken);
132
- const data = await request(token, `/spaces?${params.toString()}`);
133
- if (data.spaces) allSpaces.push(...data.spaces);
134
- pageToken = data.nextPageToken;
318
+ const data = asRecord(await request(token, `/spaces?${params.toString()}`), "spaces page");
319
+ allSpaces.push(...readArray(data, "spaces", parseSpace));
320
+ if (allSpaces.length > MAX_ITEMS) throw new GoogleChatApiError("Google Chat spaces exceeded the item limit");
321
+ pageToken = readPageToken(data);
322
+ if (pageToken && seenPageTokens.has(pageToken)) throw new GoogleChatApiError("Google Chat spaces pagination repeated a page token");
323
+ if (pageToken) seenPageTokens.add(pageToken);
135
324
  } while (pageToken);
136
325
  const dms = allSpaces.filter((s) => s.spaceType === "DIRECT_MESSAGE" && !s.singleUserBotDm);
137
326
  log.debug(`Listed ${String(allSpaces.length)} spaces, ${String(dms.length)} are DMs`);
@@ -139,36 +328,59 @@ async function listSpaces(token, log) {
139
328
  }
140
329
  /** List messages in a space after a given timestamp. */
141
330
  async function listMessages(token, spaceName, afterTimestamp, log) {
331
+ if (!isRfc3339(afterTimestamp)) throw new Error("Invalid Google Chat message timestamp");
142
332
  const filter = `createTime > "${afterTimestamp}"`;
143
- const messages = (await request(token, `/${spaceName}/messages?${new URLSearchParams({
144
- filter,
145
- orderBy: "createTime asc",
146
- pageSize: "50"
147
- }).toString()}`)).messages ?? [];
333
+ const messages = [];
334
+ const seenPageTokens = /* @__PURE__ */ new Set();
335
+ let pageToken;
336
+ let pageCount = 0;
337
+ do {
338
+ pageCount += 1;
339
+ if (pageCount > MAX_PAGES) throw new GoogleChatApiError("Google Chat messages pagination exceeded the page limit");
340
+ const params = new URLSearchParams({
341
+ filter,
342
+ orderBy: "createTime asc",
343
+ pageSize: "100"
344
+ });
345
+ if (pageToken) params.set("pageToken", pageToken);
346
+ const data = asRecord(await request(token, `/${spaceResource(spaceName)}/messages?${params.toString()}`), "messages page");
347
+ messages.push(...readArray(data, "messages", parseMessage));
348
+ if (messages.length > MAX_ITEMS) throw new GoogleChatApiError("Google Chat messages exceeded the item limit");
349
+ pageToken = readPageToken(data);
350
+ if (pageToken && seenPageTokens.has(pageToken)) throw new GoogleChatApiError("Google Chat messages pagination repeated a page token");
351
+ if (pageToken) seenPageTokens.add(pageToken);
352
+ } while (pageToken);
148
353
  if (messages.length > 0) log.debug(`Fetched ${String(messages.length)} new messages from ${spaceName}`);
149
354
  return messages;
150
355
  }
151
356
  /** Send a text message to a space. */
152
357
  async function sendMessage(token, spaceName, text) {
153
- return request(token, `/${spaceName}/messages`, {
358
+ return parseMessage(await request(token, `/${spaceResource(spaceName)}/messages`, {
154
359
  method: "POST",
155
360
  body: JSON.stringify({ text })
156
- });
361
+ }));
157
362
  }
158
363
  /** Get a specific member of a space by user resource name or email alias. */
159
364
  async function getMember(token, spaceName, userId) {
160
- return request(token, `/${spaceName}/members/${userId}`);
365
+ return parseMembership(await request(token, `/${spaceResource(spaceName)}/members/${userResource(userId)}`));
161
366
  }
162
367
  /** List members of a space. */
163
368
  async function listMembers(token, spaceName) {
164
369
  const allMembers = [];
165
370
  let pageToken;
371
+ const seenPageTokens = /* @__PURE__ */ new Set();
372
+ let pageCount = 0;
166
373
  do {
374
+ pageCount += 1;
375
+ if (pageCount > MAX_PAGES) throw new GoogleChatApiError("Google Chat members pagination exceeded the page limit");
167
376
  const params = new URLSearchParams({ pageSize: "100" });
168
377
  if (pageToken) params.set("pageToken", pageToken);
169
- const data = await request(token, `/${spaceName}/members?${params.toString()}`);
170
- if (data.memberships) allMembers.push(...data.memberships);
171
- pageToken = data.nextPageToken;
378
+ const data = asRecord(await request(token, `/${spaceResource(spaceName)}/members?${params.toString()}`), "members page");
379
+ allMembers.push(...readArray(data, "memberships", parseMembership));
380
+ if (allMembers.length > MAX_ITEMS) throw new GoogleChatApiError("Google Chat members exceeded the item limit");
381
+ pageToken = readPageToken(data);
382
+ if (pageToken && seenPageTokens.has(pageToken)) throw new GoogleChatApiError("Google Chat members pagination repeated a page token");
383
+ if (pageToken) seenPageTokens.add(pageToken);
172
384
  } while (pageToken);
173
385
  return allMembers;
174
386
  }
@@ -185,7 +397,9 @@ const CODE_BLOCK_RE = /```[\s\S]*?```/g;
185
397
  const INLINE_CODE_RE = /`[^`]+`/g;
186
398
  function markdownToGChat(text) {
187
399
  const preserved = [];
188
- const placeholder = (i) => `\x00CODE${String(i)}\x00`;
400
+ let sentinel = "\0ALFE_GCHAT_CODE_";
401
+ while (text.includes(sentinel)) sentinel += "_";
402
+ const placeholder = (i) => `${sentinel}${String(i)}\x00`;
189
403
  let result = text.replace(CODE_BLOCK_RE, (match) => {
190
404
  preserved.push(match);
191
405
  return placeholder(preserved.length - 1);
@@ -245,8 +459,8 @@ var GChatPoller = class {
245
459
  await this.discover();
246
460
  this.scheduleDiscovery();
247
461
  }
248
- /** Stop all polling. */
249
- stop() {
462
+ /** Stop all polling and durably flush the last checkpoint. */
463
+ async stop() {
250
464
  this.running = false;
251
465
  if (this.discoveryTimer) {
252
466
  clearTimeout(this.discoveryTimer);
@@ -255,9 +469,7 @@ var GChatPoller = class {
255
469
  for (const state of this.spaces.values()) if (state.pollTimer) clearTimeout(state.pollTimer);
256
470
  this.spaces.clear();
257
471
  this.seenMessages.clear();
258
- this.stateManager.stop().catch((err) => {
259
- this.log.error(`State save on stop failed: ${err instanceof Error ? err.message : String(err)}`);
260
- });
472
+ await this.stateManager.stop();
261
473
  }
262
474
  scheduleDiscovery() {
263
475
  if (!this.running) return;
@@ -267,12 +479,14 @@ var GChatPoller = class {
267
479
  }).finally(() => {
268
480
  this.scheduleDiscovery();
269
481
  });
270
- }, DISCOVERY_INTERVAL_MS);
482
+ }, this.applyApiBackoff(DISCOVERY_INTERVAL_MS));
271
483
  }
272
484
  async discover() {
273
485
  if (!this.running) return;
486
+ if (this.apiBackoffUntil > Date.now()) return;
274
487
  const token = await this.tokenManager.getAccessToken();
275
488
  const dmSpaces = await listSpaces(token, this.log);
489
+ this.recordApiSuccess();
276
490
  const currentNames = new Set(dmSpaces.map((s) => s.name));
277
491
  for (const space of dmSpaces) if (!this.spaces.has(space.name)) await this.initSpace(space.name, token);
278
492
  for (const [name, state] of this.spaces) if (!currentNames.has(name)) {
@@ -360,7 +574,7 @@ var GChatPoller = class {
360
574
  }
361
575
  scheduleSpacePoll(state) {
362
576
  if (!this.running) return;
363
- const interval = this.computeInterval(state);
577
+ const interval = this.applyApiBackoff(this.computeInterval(state));
364
578
  state.pollTimer = setTimeout(() => {
365
579
  this.pollSpace(state).catch((err) => {
366
580
  this.handleApiError(err, `poll:${state.spaceName}`);
@@ -371,6 +585,7 @@ var GChatPoller = class {
371
585
  }
372
586
  async pollSpace(state) {
373
587
  if (!this.running) return;
588
+ if (this.apiBackoffUntil > Date.now()) return;
374
589
  const token = await this.tokenManager.getAccessToken();
375
590
  let messages;
376
591
  try {
@@ -383,38 +598,59 @@ var GChatPoller = class {
383
598
  freshToken = await this.tokenManager.getAccessToken();
384
599
  } catch (refreshErr) {
385
600
  this.log.error(`Token refresh failed after 401 — stopping poller: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
386
- this.stop();
601
+ await this.stop();
387
602
  return;
388
603
  }
389
604
  messages = await listMessages(freshToken, state.spaceName, state.lastSeenTimestamp, this.log);
390
605
  } else throw err;
391
606
  }
607
+ this.recordApiSuccess();
392
608
  const seenSet = this.seenMessages.get(state.spaceName) ?? /* @__PURE__ */ new Set();
609
+ this.seenMessages.set(state.spaceName, seenSet);
610
+ let cohortTimestamp = null;
611
+ let cohortFailed = false;
393
612
  for (const msg of messages) {
613
+ if (cohortTimestamp !== null && msg.createTime !== cohortTimestamp) this.commitTimestamp(cohortTimestamp, state);
614
+ cohortTimestamp = msg.createTime;
394
615
  if (seenSet.has(msg.name)) continue;
395
- seenSet.add(msg.name);
396
- if (seenSet.size > DEDUP_SIZE) {
397
- const iter = seenSet.values().next();
398
- if (!iter.done) seenSet.delete(iter.value);
616
+ if (msg.sender.name === state.agentUserId) {
617
+ this.rememberMessage(msg, seenSet);
618
+ continue;
619
+ }
620
+ if (!msg.text) {
621
+ this.rememberMessage(msg, seenSet);
622
+ continue;
399
623
  }
400
- state.lastSeenTimestamp = msg.createTime;
401
- this.stateManager.updateSpace(state.spaceName, { lastSeenTimestamp: msg.createTime });
402
- if (msg.sender.name === state.agentUserId) continue;
403
- if (!msg.text) continue;
404
624
  state.lastMessageAt = Date.now();
405
- await this.dispatchMessage(msg, state);
625
+ if (!await this.dispatchMessage(msg, state)) {
626
+ cohortFailed = true;
627
+ break;
628
+ }
629
+ this.rememberMessage(msg, seenSet);
406
630
  }
631
+ if (!cohortFailed && cohortTimestamp !== null) this.commitTimestamp(cohortTimestamp, state);
632
+ }
633
+ rememberMessage(message, seenSet) {
634
+ seenSet.add(message.name);
635
+ if (seenSet.size > DEDUP_SIZE) {
636
+ const oldest = seenSet.values().next();
637
+ if (!oldest.done) seenSet.delete(oldest.value);
638
+ }
639
+ }
640
+ commitTimestamp(timestamp, state) {
641
+ state.lastSeenTimestamp = timestamp;
642
+ this.stateManager.updateSpace(state.spaceName, { lastSeenTimestamp: timestamp });
407
643
  }
408
644
  async dispatchMessage(message, state) {
409
645
  if (!this.dispatchInbound || !this.runtime) {
410
646
  this.log.warn("Cannot dispatch — SDK or runtime not available");
411
- return;
647
+ return false;
412
648
  }
413
649
  const conversationId = `alfe:gchat:${state.spaceName}`;
414
650
  const senderName = message.sender.name || state.peerUserId;
415
651
  const senderDisplayName = message.sender.displayName;
416
652
  const peerLabel = senderDisplayName || state.peerDisplayName || "User";
417
- this.log.info(`Dispatching message from ${peerLabel} in ${state.spaceName}`);
653
+ this.log.info(`Dispatching Google Chat DM in ${state.spaceName}`);
418
654
  let resolvedIdentityId;
419
655
  if (this.agentClient) try {
420
656
  const senderUserId = senderName.startsWith("users/") ? senderName.slice(6) : senderName;
@@ -427,6 +663,7 @@ var GChatPoller = class {
427
663
  }
428
664
  try {
429
665
  const cfg = this.runtime.config.loadConfig();
666
+ let callbackFailed = false;
430
667
  await this.dispatchInbound({
431
668
  cfg,
432
669
  runtime: { channel: this.runtime.channel },
@@ -471,14 +708,18 @@ var GChatPoller = class {
471
708
  }
472
709
  },
473
710
  onRecordError: (err) => {
711
+ callbackFailed = true;
474
712
  this.log.error(`Session error: ${err instanceof Error ? err.message : String(err)}`);
475
713
  },
476
714
  onDispatchError: (err, info) => {
715
+ callbackFailed = true;
477
716
  this.log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
478
717
  }
479
718
  });
719
+ return !callbackFailed;
480
720
  } catch (err) {
481
721
  this.log.error(`Failed to dispatch message: ${err instanceof Error ? err.message : String(err)}`);
722
+ return false;
482
723
  }
483
724
  }
484
725
  computeInterval(state) {
@@ -490,26 +731,40 @@ var GChatPoller = class {
490
731
  return INTERVAL_DORMANT_MS;
491
732
  }
492
733
  retryCount = 0;
734
+ apiBackoffUntil = 0;
493
735
  handleApiError(err, context) {
494
736
  if (isHttpStatus(err, 403) || isTokenRevokedError(err)) {
495
737
  this.log.error(`Token revoked or forbidden (${context}) — stopping poller`);
496
- this.stop();
738
+ this.stop().catch((stopErr) => {
739
+ this.log.error(`Poller stop failed: ${stopErr instanceof Error ? stopErr.message : String(stopErr)}`);
740
+ });
497
741
  return;
498
742
  }
499
743
  if (isHttpStatus(err, 429)) {
500
744
  this.retryCount = Math.min(this.retryCount + 1, 5);
501
745
  const delay = Math.min(BASE_RETRY_DELAY_MS * Math.pow(2, this.retryCount), MAX_RETRY_DELAY_MS);
746
+ this.apiBackoffUntil = Math.max(this.apiBackoffUntil, Date.now() + delay);
502
747
  this.log.warn(`Rate limited (${context}) — backing off ${String(delay)}ms`);
503
748
  return;
504
749
  }
505
750
  if (isHttpStatus(err, 404)) {
506
751
  this.log.error(`Google Chat API returned 404 (${context}) — verify the Chat app is configured in the GCP Console: https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat`);
507
- this.stop();
752
+ this.stop().catch((stopErr) => {
753
+ this.log.error(`Poller stop failed: ${stopErr instanceof Error ? stopErr.message : String(stopErr)}`);
754
+ });
508
755
  return;
509
756
  }
510
- this.retryCount = 0;
511
757
  this.log.error(`API error in ${context}: ${err instanceof Error ? err.message : String(err)}`);
512
758
  }
759
+ applyApiBackoff(baseDelay) {
760
+ return Math.max(baseDelay, this.apiBackoffUntil - Date.now(), 0);
761
+ }
762
+ recordApiSuccess() {
763
+ if (Date.now() >= this.apiBackoffUntil) {
764
+ this.retryCount = 0;
765
+ this.apiBackoffUntil = 0;
766
+ }
767
+ }
513
768
  };
514
769
  function isHttpStatus(err, status) {
515
770
  return typeof err === "object" && err !== null && "status" in err && err.status === status;
@@ -534,10 +789,9 @@ function isTokenRevokedError(err) {
534
789
  * ← deliver() callback → Google Chat API (reply as user)
535
790
  */
536
791
  const pkg = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
537
- let dispatchInbound = null;
538
792
  const GCHAT_ACTIVATION_KEY = (0, _alfe_ai_openclaw_plugin_kit.getActivationKey)("google-chat");
539
- let pluginRuntime = null;
540
793
  let poller = null;
794
+ let serviceGeneration = 0;
541
795
  const plugin = {
542
796
  id: "@alfe.ai/openclaw-google-chat",
543
797
  name: "Google Chat",
@@ -547,51 +801,57 @@ const plugin = {
547
801
  const log = api.logger;
548
802
  const startService = () => {
549
803
  (0, _alfe_ai_openclaw_plugin_kit.guardedStart)(GCHAT_ACTIVATION_KEY, log, async () => {
550
- log.info("Google Chat plugin starting...");
551
- dispatchInbound = (0, _alfe_ai_openclaw_plugin_kit.resolveOpenClawSdk)(log, { unresolvableNote: "OpenClaw SDK not resolvable — Google Chat dispatch will not work" });
552
- pluginRuntime = api.runtime ?? null;
553
- let client;
804
+ const generation = ++serviceGeneration;
805
+ let nextPoller = null;
554
806
  try {
807
+ log.info("Google Chat plugin starting...");
808
+ const resolvedDispatch = (0, _alfe_ai_openclaw_plugin_kit.resolveOpenClawSdk)(log, { unresolvableNote: "OpenClaw SDK not resolvable — Google Chat dispatch will not work" });
809
+ const resolvedRuntime = api.runtime ?? null;
810
+ if (!resolvedDispatch || !resolvedRuntime) {
811
+ if (generation === serviceGeneration) (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(GCHAT_ACTIVATION_KEY);
812
+ log.error("Google Chat runtime prerequisites unavailable — polling disabled");
813
+ return;
814
+ }
555
815
  const cfg = (0, _alfe_ai_config.resolveConfig)();
556
- client = new _alfe_ai_agent_api_client.AgentApiClient({
816
+ const client = new _alfe_ai_agent_api_client.AgentApiClient({
557
817
  apiKey: cfg.apiKey,
558
818
  apiUrl: cfg.apiUrl
559
819
  });
820
+ const creds = await client.getGoogleChatCredentials();
821
+ if (generation !== serviceGeneration) return;
822
+ nextPoller = new GChatPoller({
823
+ tokenManager: new (await (Promise.resolve().then(() => require("./gchat-token.cjs")))).TokenManager({
824
+ refreshToken: creds.refreshToken,
825
+ clientId: creds.clientId,
826
+ clientSecret: creds.clientSecret
827
+ }, log),
828
+ dispatchInbound: resolvedDispatch,
829
+ runtime: resolvedRuntime,
830
+ log,
831
+ agentEmail: creds.email,
832
+ agentClient: client
833
+ });
834
+ await nextPoller.start();
835
+ if (generation !== serviceGeneration) {
836
+ await nextPoller.stop();
837
+ return;
838
+ }
839
+ poller = nextPoller;
840
+ log.info("Google Chat poller started");
560
841
  } catch (err) {
561
- (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(GCHAT_ACTIVATION_KEY);
562
- log.error(`Failed to resolve config: ${err instanceof Error ? err.message : String(err)}`);
563
- return;
564
- }
565
- let creds;
566
- try {
567
- creds = await client.getGoogleChatCredentials();
568
- } catch (err) {
569
- (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(GCHAT_ACTIVATION_KEY);
570
- log.info(`Google Chat not connected — polling disabled (${err instanceof Error ? err.message : String(err)})`);
571
- return;
842
+ await nextPoller?.stop().catch(() => void 0);
843
+ if (generation === serviceGeneration) {
844
+ (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(GCHAT_ACTIVATION_KEY);
845
+ log.info(`Google Chat unavailable — polling disabled (${err instanceof Error ? err.message : String(err)})`);
846
+ }
572
847
  }
573
- const init = {
574
- tokenManager: new (await (Promise.resolve().then(() => require("./gchat-token.cjs")))).TokenManager({
575
- refreshToken: creds.refreshToken,
576
- clientId: creds.clientId,
577
- clientSecret: creds.clientSecret
578
- }, log),
579
- dispatchInbound,
580
- runtime: pluginRuntime,
581
- log,
582
- agentEmail: creds.email
583
- };
584
- init.agentClient = client;
585
- poller = new GChatPoller(init);
586
- await poller.start();
587
- log.info(`Google Chat poller started (account: ${creds.email})`);
588
848
  });
589
849
  };
590
- const stopService = () => {
591
- poller?.stop();
850
+ const stopService = async () => {
851
+ serviceGeneration += 1;
852
+ const currentPoller = poller;
592
853
  poller = null;
593
- pluginRuntime = null;
594
- dispatchInbound = null;
854
+ await currentPoller?.stop();
595
855
  (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(GCHAT_ACTIVATION_KEY);
596
856
  log.info("Google Chat plugin stopped");
597
857
  };
@@ -600,18 +860,16 @@ const plugin = {
600
860
  start: () => {
601
861
  startService();
602
862
  },
603
- stop: () => {
604
- stopService();
605
- }
863
+ stop: stopService
606
864
  });
607
865
  log.info("Google Chat plugin registered");
608
866
  },
609
- deactivate(api) {
867
+ async deactivate(api) {
610
868
  const log = api.logger;
611
- poller?.stop();
869
+ serviceGeneration += 1;
870
+ const currentPoller = poller;
612
871
  poller = null;
613
- pluginRuntime = null;
614
- dispatchInbound = null;
872
+ await currentPoller?.stop();
615
873
  (0, _alfe_ai_openclaw_plugin_kit.resetActivation)(GCHAT_ACTIVATION_KEY);
616
874
  log.info("Google Chat plugin deactivated");
617
875
  }