@alfe.ai/openclaw-google-chat 0.0.29 → 0.0.31
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/README.md +27 -0
- package/dist/gchat-token.cjs +56 -6
- package/dist/gchat-token.js +56 -6
- package/dist/plugin.d.cts +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin2.cjs +364 -106
- package/dist/plugin2.js +366 -108
- package/package.json +4 -4
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
|
|
13
|
-
*
|
|
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
|
|
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
|
-
|
|
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)(
|
|
30
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
101
|
+
this.markDirty();
|
|
95
102
|
}
|
|
96
103
|
/** Write state to disk immediately. */
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
121
|
-
throw
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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 =
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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
|
-
|
|
551
|
-
|
|
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
|
-
(
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
850
|
+
const stopService = async () => {
|
|
851
|
+
serviceGeneration += 1;
|
|
852
|
+
const currentPoller = poller;
|
|
592
853
|
poller = null;
|
|
593
|
-
|
|
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
|
-
|
|
869
|
+
serviceGeneration += 1;
|
|
870
|
+
const currentPoller = poller;
|
|
612
871
|
poller = null;
|
|
613
|
-
|
|
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
|
}
|