@alfe.ai/openclaw-google-chat 0.0.28 → 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.js CHANGED
@@ -1,35 +1,42 @@
1
1
  import { createRequire } from "node:module";
2
- import { dirname, join, resolve } from "node:path";
3
2
  import { resolveConfig } from "@alfe.ai/config";
4
3
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
5
- import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { getActivationKey, guardedStart, resetActivation, resolveOpenClawSdk } from "@alfe.ai/openclaw-plugin-kit";
5
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
6
7
  import { homedir } from "node:os";
7
8
  //#region src/gchat-state.ts
8
9
  /**
9
10
  * State persistence — saves poller state to ~/.alfe/state/google-chat-poller.json.
10
11
  *
11
- * Written every 30s (debounced). Read on startup. Defaults to now-5min if
12
- * 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.
13
14
  */
14
- const STATE_DIR = join(homedir(), ".alfe", "state");
15
- const STATE_FILE = join(STATE_DIR, "google-chat-poller.json");
15
+ const STATE_FILE = join(join(homedir(), ".alfe", "state"), "google-chat-poller.json");
16
16
  const SAVE_INTERVAL_MS = 3e4;
17
17
  var StateManager = class {
18
18
  state = { spaces: {} };
19
19
  dirty = false;
20
+ revision = 0;
21
+ flushPromise = null;
20
22
  saveTimer = null;
21
23
  log;
22
- constructor(log) {
24
+ stateFile;
25
+ constructor(log, stateFile = STATE_FILE) {
23
26
  this.log = log;
27
+ this.stateFile = stateFile;
24
28
  }
25
29
  /** Load state from disk. Returns default state if file doesn't exist. */
26
30
  async load() {
27
31
  try {
28
- const raw = await readFile(STATE_FILE, "utf-8");
29
- this.state = JSON.parse(raw);
32
+ const raw = await 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;
30
36
  this.log.info(`Loaded state: ${String(Object.keys(this.state.spaces).length)} known spaces`);
31
- } catch {
32
- 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)}`);
33
40
  this.state = { spaces: {} };
34
41
  }
35
42
  return this.state;
@@ -49,7 +56,8 @@ var StateManager = class {
49
56
  clearInterval(this.saveTimer);
50
57
  this.saveTimer = null;
51
58
  }
52
- if (this.dirty) await this.flush();
59
+ while (this.dirty || this.flushPromise) if (this.flushPromise) await this.flushPromise;
60
+ else await this.flush();
53
61
  }
54
62
  /** Get the last-seen timestamp for a space, or undefined if not yet tracked. */
55
63
  getLastSeenTimestamp(spaceName) {
@@ -64,7 +72,7 @@ var StateManager = class {
64
72
  setAgentIdentity(userId, email) {
65
73
  this.state.agentUserId = userId;
66
74
  this.state.agentEmail = email;
67
- this.dirty = true;
75
+ this.markDirty();
68
76
  }
69
77
  /** Get the agent's userId for a space (if previously resolved). */
70
78
  getAgentUserId(spaceName) {
@@ -85,27 +93,75 @@ var StateManager = class {
85
93
  ...existing,
86
94
  ...data
87
95
  };
88
- this.dirty = true;
96
+ this.markDirty();
89
97
  }
90
98
  /** Remove a space from tracked state. */
91
99
  removeSpace(spaceName) {
92
100
  delete this.state.spaces[spaceName];
93
- this.dirty = true;
101
+ this.markDirty();
94
102
  }
95
103
  /** Write state to disk immediately. */
96
- async flush() {
97
- try {
98
- await mkdir(STATE_DIR, { recursive: true });
99
- await writeFile(STATE_FILE, JSON.stringify(this.state, null, 2));
100
- this.dirty = false;
101
- } catch (err) {
102
- this.log.error(`Failed to write state: ${err instanceof Error ? err.message : String(err)}`);
103
- }
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 = dirname(this.stateFile);
110
+ const temporaryFile = `${this.stateFile}.${String(process.pid)}.tmp`;
111
+ this.flushPromise = (async () => {
112
+ try {
113
+ await mkdir(stateDir, {
114
+ recursive: true,
115
+ mode: 448
116
+ });
117
+ await writeFile(temporaryFile, contents, { mode: 384 });
118
+ await rename(temporaryFile, this.stateFile);
119
+ if (this.revision === revision) this.dirty = false;
120
+ } catch (err) {
121
+ await 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;
104
133
  }
105
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
+ }
106
151
  //#endregion
107
152
  //#region src/gchat-api.ts
108
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
+ };
109
165
  async function request(token, path, options) {
110
166
  const url = `${CHAT_API}${path}`;
111
167
  const headers = new Headers(options?.headers);
@@ -113,24 +169,158 @@ async function request(token, path, options) {
113
169
  headers.set("Content-Type", "application/json");
114
170
  const res = await fetch(url, {
115
171
  ...options,
116
- headers
172
+ headers,
173
+ signal: options?.signal ?? AbortSignal.timeout(REQUEST_TIMEOUT_MS)
117
174
  });
118
175
  if (!res.ok) {
119
- const body = await res.text().catch(() => "");
120
- 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");
184
+ }
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");
121
242
  }
122
- return await res.json();
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])}`;
123
306
  }
124
307
  /** List all spaces the user is a member of. */
125
308
  async function listSpaces(token, log) {
126
309
  const allSpaces = [];
127
310
  let pageToken;
311
+ const seenPageTokens = /* @__PURE__ */ new Set();
312
+ let pageCount = 0;
128
313
  do {
314
+ pageCount += 1;
315
+ if (pageCount > MAX_PAGES) throw new GoogleChatApiError("Google Chat spaces pagination exceeded the page limit");
129
316
  const params = new URLSearchParams({ pageSize: "100" });
130
317
  if (pageToken) params.set("pageToken", pageToken);
131
- const data = await request(token, `/spaces?${params.toString()}`);
132
- if (data.spaces) allSpaces.push(...data.spaces);
133
- 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);
134
324
  } while (pageToken);
135
325
  const dms = allSpaces.filter((s) => s.spaceType === "DIRECT_MESSAGE" && !s.singleUserBotDm);
136
326
  log.debug(`Listed ${String(allSpaces.length)} spaces, ${String(dms.length)} are DMs`);
@@ -138,36 +328,59 @@ async function listSpaces(token, log) {
138
328
  }
139
329
  /** List messages in a space after a given timestamp. */
140
330
  async function listMessages(token, spaceName, afterTimestamp, log) {
331
+ if (!isRfc3339(afterTimestamp)) throw new Error("Invalid Google Chat message timestamp");
141
332
  const filter = `createTime > "${afterTimestamp}"`;
142
- const messages = (await request(token, `/${spaceName}/messages?${new URLSearchParams({
143
- filter,
144
- orderBy: "createTime asc",
145
- pageSize: "50"
146
- }).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);
147
353
  if (messages.length > 0) log.debug(`Fetched ${String(messages.length)} new messages from ${spaceName}`);
148
354
  return messages;
149
355
  }
150
356
  /** Send a text message to a space. */
151
357
  async function sendMessage(token, spaceName, text) {
152
- return request(token, `/${spaceName}/messages`, {
358
+ return parseMessage(await request(token, `/${spaceResource(spaceName)}/messages`, {
153
359
  method: "POST",
154
360
  body: JSON.stringify({ text })
155
- });
361
+ }));
156
362
  }
157
363
  /** Get a specific member of a space by user resource name or email alias. */
158
364
  async function getMember(token, spaceName, userId) {
159
- return request(token, `/${spaceName}/members/${userId}`);
365
+ return parseMembership(await request(token, `/${spaceResource(spaceName)}/members/${userResource(userId)}`));
160
366
  }
161
367
  /** List members of a space. */
162
368
  async function listMembers(token, spaceName) {
163
369
  const allMembers = [];
164
370
  let pageToken;
371
+ const seenPageTokens = /* @__PURE__ */ new Set();
372
+ let pageCount = 0;
165
373
  do {
374
+ pageCount += 1;
375
+ if (pageCount > MAX_PAGES) throw new GoogleChatApiError("Google Chat members pagination exceeded the page limit");
166
376
  const params = new URLSearchParams({ pageSize: "100" });
167
377
  if (pageToken) params.set("pageToken", pageToken);
168
- const data = await request(token, `/${spaceName}/members?${params.toString()}`);
169
- if (data.memberships) allMembers.push(...data.memberships);
170
- 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);
171
384
  } while (pageToken);
172
385
  return allMembers;
173
386
  }
@@ -184,7 +397,9 @@ const CODE_BLOCK_RE = /```[\s\S]*?```/g;
184
397
  const INLINE_CODE_RE = /`[^`]+`/g;
185
398
  function markdownToGChat(text) {
186
399
  const preserved = [];
187
- 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`;
188
403
  let result = text.replace(CODE_BLOCK_RE, (match) => {
189
404
  preserved.push(match);
190
405
  return placeholder(preserved.length - 1);
@@ -244,8 +459,8 @@ var GChatPoller = class {
244
459
  await this.discover();
245
460
  this.scheduleDiscovery();
246
461
  }
247
- /** Stop all polling. */
248
- stop() {
462
+ /** Stop all polling and durably flush the last checkpoint. */
463
+ async stop() {
249
464
  this.running = false;
250
465
  if (this.discoveryTimer) {
251
466
  clearTimeout(this.discoveryTimer);
@@ -254,9 +469,7 @@ var GChatPoller = class {
254
469
  for (const state of this.spaces.values()) if (state.pollTimer) clearTimeout(state.pollTimer);
255
470
  this.spaces.clear();
256
471
  this.seenMessages.clear();
257
- this.stateManager.stop().catch((err) => {
258
- this.log.error(`State save on stop failed: ${err instanceof Error ? err.message : String(err)}`);
259
- });
472
+ await this.stateManager.stop();
260
473
  }
261
474
  scheduleDiscovery() {
262
475
  if (!this.running) return;
@@ -266,12 +479,14 @@ var GChatPoller = class {
266
479
  }).finally(() => {
267
480
  this.scheduleDiscovery();
268
481
  });
269
- }, DISCOVERY_INTERVAL_MS);
482
+ }, this.applyApiBackoff(DISCOVERY_INTERVAL_MS));
270
483
  }
271
484
  async discover() {
272
485
  if (!this.running) return;
486
+ if (this.apiBackoffUntil > Date.now()) return;
273
487
  const token = await this.tokenManager.getAccessToken();
274
488
  const dmSpaces = await listSpaces(token, this.log);
489
+ this.recordApiSuccess();
275
490
  const currentNames = new Set(dmSpaces.map((s) => s.name));
276
491
  for (const space of dmSpaces) if (!this.spaces.has(space.name)) await this.initSpace(space.name, token);
277
492
  for (const [name, state] of this.spaces) if (!currentNames.has(name)) {
@@ -359,7 +574,7 @@ var GChatPoller = class {
359
574
  }
360
575
  scheduleSpacePoll(state) {
361
576
  if (!this.running) return;
362
- const interval = this.computeInterval(state);
577
+ const interval = this.applyApiBackoff(this.computeInterval(state));
363
578
  state.pollTimer = setTimeout(() => {
364
579
  this.pollSpace(state).catch((err) => {
365
580
  this.handleApiError(err, `poll:${state.spaceName}`);
@@ -370,6 +585,7 @@ var GChatPoller = class {
370
585
  }
371
586
  async pollSpace(state) {
372
587
  if (!this.running) return;
588
+ if (this.apiBackoffUntil > Date.now()) return;
373
589
  const token = await this.tokenManager.getAccessToken();
374
590
  let messages;
375
591
  try {
@@ -382,38 +598,59 @@ var GChatPoller = class {
382
598
  freshToken = await this.tokenManager.getAccessToken();
383
599
  } catch (refreshErr) {
384
600
  this.log.error(`Token refresh failed after 401 — stopping poller: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
385
- this.stop();
601
+ await this.stop();
386
602
  return;
387
603
  }
388
604
  messages = await listMessages(freshToken, state.spaceName, state.lastSeenTimestamp, this.log);
389
605
  } else throw err;
390
606
  }
607
+ this.recordApiSuccess();
391
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;
392
612
  for (const msg of messages) {
613
+ if (cohortTimestamp !== null && msg.createTime !== cohortTimestamp) this.commitTimestamp(cohortTimestamp, state);
614
+ cohortTimestamp = msg.createTime;
393
615
  if (seenSet.has(msg.name)) continue;
394
- seenSet.add(msg.name);
395
- if (seenSet.size > DEDUP_SIZE) {
396
- const iter = seenSet.values().next();
397
- 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;
398
623
  }
399
- state.lastSeenTimestamp = msg.createTime;
400
- this.stateManager.updateSpace(state.spaceName, { lastSeenTimestamp: msg.createTime });
401
- if (msg.sender.name === state.agentUserId) continue;
402
- if (!msg.text) continue;
403
624
  state.lastMessageAt = Date.now();
404
- await this.dispatchMessage(msg, state);
625
+ if (!await this.dispatchMessage(msg, state)) {
626
+ cohortFailed = true;
627
+ break;
628
+ }
629
+ this.rememberMessage(msg, seenSet);
405
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 });
406
643
  }
407
644
  async dispatchMessage(message, state) {
408
645
  if (!this.dispatchInbound || !this.runtime) {
409
646
  this.log.warn("Cannot dispatch — SDK or runtime not available");
410
- return;
647
+ return false;
411
648
  }
412
649
  const conversationId = `alfe:gchat:${state.spaceName}`;
413
650
  const senderName = message.sender.name || state.peerUserId;
414
651
  const senderDisplayName = message.sender.displayName;
415
652
  const peerLabel = senderDisplayName || state.peerDisplayName || "User";
416
- this.log.info(`Dispatching message from ${peerLabel} in ${state.spaceName}`);
653
+ this.log.info(`Dispatching Google Chat DM in ${state.spaceName}`);
417
654
  let resolvedIdentityId;
418
655
  if (this.agentClient) try {
419
656
  const senderUserId = senderName.startsWith("users/") ? senderName.slice(6) : senderName;
@@ -426,6 +663,7 @@ var GChatPoller = class {
426
663
  }
427
664
  try {
428
665
  const cfg = this.runtime.config.loadConfig();
666
+ let callbackFailed = false;
429
667
  await this.dispatchInbound({
430
668
  cfg,
431
669
  runtime: { channel: this.runtime.channel },
@@ -470,14 +708,18 @@ var GChatPoller = class {
470
708
  }
471
709
  },
472
710
  onRecordError: (err) => {
711
+ callbackFailed = true;
473
712
  this.log.error(`Session error: ${err instanceof Error ? err.message : String(err)}`);
474
713
  },
475
714
  onDispatchError: (err, info) => {
715
+ callbackFailed = true;
476
716
  this.log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
477
717
  }
478
718
  });
719
+ return !callbackFailed;
479
720
  } catch (err) {
480
721
  this.log.error(`Failed to dispatch message: ${err instanceof Error ? err.message : String(err)}`);
722
+ return false;
481
723
  }
482
724
  }
483
725
  computeInterval(state) {
@@ -489,26 +731,40 @@ var GChatPoller = class {
489
731
  return INTERVAL_DORMANT_MS;
490
732
  }
491
733
  retryCount = 0;
734
+ apiBackoffUntil = 0;
492
735
  handleApiError(err, context) {
493
736
  if (isHttpStatus(err, 403) || isTokenRevokedError(err)) {
494
737
  this.log.error(`Token revoked or forbidden (${context}) — stopping poller`);
495
- this.stop();
738
+ this.stop().catch((stopErr) => {
739
+ this.log.error(`Poller stop failed: ${stopErr instanceof Error ? stopErr.message : String(stopErr)}`);
740
+ });
496
741
  return;
497
742
  }
498
743
  if (isHttpStatus(err, 429)) {
499
744
  this.retryCount = Math.min(this.retryCount + 1, 5);
500
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);
501
747
  this.log.warn(`Rate limited (${context}) — backing off ${String(delay)}ms`);
502
748
  return;
503
749
  }
504
750
  if (isHttpStatus(err, 404)) {
505
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`);
506
- this.stop();
752
+ this.stop().catch((stopErr) => {
753
+ this.log.error(`Poller stop failed: ${stopErr instanceof Error ? stopErr.message : String(stopErr)}`);
754
+ });
507
755
  return;
508
756
  }
509
- this.retryCount = 0;
510
757
  this.log.error(`API error in ${context}: ${err instanceof Error ? err.message : String(err)}`);
511
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
+ }
512
768
  };
513
769
  function isHttpStatus(err, status) {
514
770
  return typeof err === "object" && err !== null && "status" in err && err.status === status;
@@ -532,36 +788,10 @@ function isTokenRevokedError(err) {
532
788
  * → dispatchInboundDirectDmWithRuntime() → Agent pipeline
533
789
  * ← deliver() callback → Google Chat API (reply as user)
534
790
  */
535
- const require = createRequire(import.meta.url);
536
- const pkg = require("../package.json");
537
- let dispatchInbound = null;
538
- /**
539
- * Resolve OpenClaw SDK from the running process.
540
- * Copied from @alfe.ai/openclaw-chat's resolution strategy.
541
- */
542
- function resolveOpenClawSdk(log) {
543
- const anchors = [require.main?.filename, process.argv[1]].filter(Boolean);
544
- for (const anchor of anchors) try {
545
- const channelInbound = createRequire(anchor)("openclaw/plugin-sdk/channel-inbound");
546
- if (channelInbound.dispatchInboundDirectDmWithRuntime) {
547
- dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
548
- log.info(`Resolved OpenClaw SDK from ${anchor}`);
549
- return;
550
- }
551
- } catch {}
552
- try {
553
- const derivedPath = join(resolve(dirname(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
554
- const channelInbound = createRequire(derivedPath)("openclaw/plugin-sdk/channel-inbound");
555
- if (channelInbound.dispatchInboundDirectDmWithRuntime) {
556
- dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
557
- log.info(`Resolved OpenClaw SDK from ${derivedPath}`);
558
- return;
559
- }
560
- } catch {}
561
- log.warn("OpenClaw SDK not resolvable — Google Chat dispatch will not work");
562
- }
563
- let pluginRuntime = null;
791
+ const pkg = createRequire(import.meta.url)("../package.json");
792
+ const GCHAT_ACTIVATION_KEY = getActivationKey("google-chat");
564
793
  let poller = null;
794
+ let serviceGeneration = 0;
565
795
  const plugin = {
566
796
  id: "@alfe.ai/openclaw-google-chat",
567
797
  name: "Google Chat",
@@ -569,80 +799,78 @@ const plugin = {
569
799
  version: pkg.version,
570
800
  activate(api) {
571
801
  const log = api.logger;
572
- const startService = async () => {
573
- if (globalThis.__alfeGoogleChatActivated) {
574
- log.debug("Google Chat plugin already activated — skipping duplicate");
575
- return;
576
- }
577
- globalThis.__alfeGoogleChatActivated = true;
578
- log.info("Google Chat plugin starting...");
579
- resolveOpenClawSdk(log);
580
- pluginRuntime = api.runtime ?? null;
581
- let client;
582
- try {
583
- const cfg = resolveConfig();
584
- client = new AgentApiClient({
585
- apiKey: cfg.apiKey,
586
- apiUrl: cfg.apiUrl
587
- });
588
- } catch (err) {
589
- globalThis.__alfeGoogleChatActivated = false;
590
- log.error(`Failed to resolve config: ${err instanceof Error ? err.message : String(err)}`);
591
- return;
592
- }
593
- let creds;
594
- try {
595
- creds = await client.getGoogleChatCredentials();
596
- } catch (err) {
597
- globalThis.__alfeGoogleChatActivated = false;
598
- log.info(`Google Chat not connected — polling disabled (${err instanceof Error ? err.message : String(err)})`);
599
- return;
600
- }
601
- const init = {
602
- tokenManager: new (await (import("./gchat-token.js"))).TokenManager({
603
- refreshToken: creds.refreshToken,
604
- clientId: creds.clientId,
605
- clientSecret: creds.clientSecret
606
- }, log),
607
- dispatchInbound,
608
- runtime: pluginRuntime,
609
- log,
610
- agentEmail: creds.email
611
- };
612
- init.agentClient = client;
613
- poller = new GChatPoller(init);
614
- await poller.start();
615
- log.info(`Google Chat poller started (account: ${creds.email})`);
802
+ const startService = () => {
803
+ guardedStart(GCHAT_ACTIVATION_KEY, log, async () => {
804
+ const generation = ++serviceGeneration;
805
+ let nextPoller = null;
806
+ try {
807
+ log.info("Google Chat plugin starting...");
808
+ const resolvedDispatch = 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) resetActivation(GCHAT_ACTIVATION_KEY);
812
+ log.error("Google Chat runtime prerequisites unavailable — polling disabled");
813
+ return;
814
+ }
815
+ const cfg = resolveConfig();
816
+ const client = new AgentApiClient({
817
+ apiKey: cfg.apiKey,
818
+ apiUrl: cfg.apiUrl
819
+ });
820
+ const creds = await client.getGoogleChatCredentials();
821
+ if (generation !== serviceGeneration) return;
822
+ nextPoller = new GChatPoller({
823
+ tokenManager: new (await (import("./gchat-token.js"))).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");
841
+ } catch (err) {
842
+ await nextPoller?.stop().catch(() => void 0);
843
+ if (generation === serviceGeneration) {
844
+ resetActivation(GCHAT_ACTIVATION_KEY);
845
+ log.info(`Google Chat unavailable polling disabled (${err instanceof Error ? err.message : String(err)})`);
846
+ }
847
+ }
848
+ });
616
849
  };
617
- const stopService = () => {
618
- globalThis.__alfeGoogleChatActivated = false;
619
- poller?.stop();
850
+ const stopService = async () => {
851
+ serviceGeneration += 1;
852
+ const currentPoller = poller;
620
853
  poller = null;
621
- pluginRuntime = null;
622
- dispatchInbound = null;
854
+ await currentPoller?.stop();
855
+ resetActivation(GCHAT_ACTIVATION_KEY);
623
856
  log.info("Google Chat plugin stopped");
624
857
  };
625
858
  api.registerService({
626
859
  id: "google-chat-poller",
627
860
  start: () => {
628
- startService().catch((err) => {
629
- globalThis.__alfeGoogleChatActivated = false;
630
- log.error(`Google Chat service start failed: ${err instanceof Error ? err.message : String(err)}`);
631
- });
861
+ startService();
632
862
  },
633
- stop: () => {
634
- stopService();
635
- }
863
+ stop: stopService
636
864
  });
637
865
  log.info("Google Chat plugin registered");
638
866
  },
639
- deactivate(api) {
640
- globalThis.__alfeGoogleChatActivated = false;
867
+ async deactivate(api) {
641
868
  const log = api.logger;
642
- poller?.stop();
869
+ serviceGeneration += 1;
870
+ const currentPoller = poller;
643
871
  poller = null;
644
- pluginRuntime = null;
645
- dispatchInbound = null;
872
+ await currentPoller?.stop();
873
+ resetActivation(GCHAT_ACTIVATION_KEY);
646
874
  log.info("Google Chat plugin deactivated");
647
875
  }
648
876
  };