@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/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 +381 -153
- package/dist/plugin2.js +382 -154
- package/package.json +4 -3
package/dist/plugin2.cjs
CHANGED
|
@@ -1,35 +1,42 @@
|
|
|
1
1
|
let node_module = require("node:module");
|
|
2
|
-
let node_path = require("node:path");
|
|
3
2
|
let _alfe_ai_config = require("@alfe.ai/config");
|
|
4
3
|
let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
|
|
4
|
+
let _alfe_ai_openclaw_plugin_kit = require("@alfe.ai/openclaw-plugin-kit");
|
|
5
5
|
let node_fs_promises = require("node:fs/promises");
|
|
6
|
+
let node_path = require("node:path");
|
|
6
7
|
let node_os = require("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
|
|
12
|
-
*
|
|
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
|
|
15
|
-
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");
|
|
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
|
-
|
|
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 (0, node_fs_promises.readFile)(
|
|
29
|
-
|
|
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;
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
101
|
+
this.markDirty();
|
|
94
102
|
}
|
|
95
103
|
/** Write state to disk immediately. */
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 = (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;
|
|
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
|
-
|
|
120
|
-
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");
|
|
121
184
|
}
|
|
122
|
-
|
|
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])}`;
|
|
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
|
-
|
|
133
|
-
|
|
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 =
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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);
|
|
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);
|
|
405
638
|
}
|
|
406
639
|
}
|
|
640
|
+
commitTimestamp(timestamp, state) {
|
|
641
|
+
state.lastSeenTimestamp = timestamp;
|
|
642
|
+
this.stateManager.updateSpace(state.spaceName, { lastSeenTimestamp: timestamp });
|
|
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
|
|
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
|
|
536
|
-
const
|
|
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$1.main?.filename, process.argv[1]].filter(Boolean);
|
|
544
|
-
for (const anchor of anchors) try {
|
|
545
|
-
const channelInbound = (0, node_module.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 = (0, node_path.join)((0, node_path.resolve)((0, node_path.dirname)(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
|
|
554
|
-
const channelInbound = (0, node_module.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 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
|
|
792
|
+
const GCHAT_ACTIVATION_KEY = (0, _alfe_ai_openclaw_plugin_kit.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 =
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
802
|
+
const startService = () => {
|
|
803
|
+
(0, _alfe_ai_openclaw_plugin_kit.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 = (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
|
+
}
|
|
815
|
+
const cfg = (0, _alfe_ai_config.resolveConfig)();
|
|
816
|
+
const client = new _alfe_ai_agent_api_client.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 (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");
|
|
841
|
+
} catch (err) {
|
|
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
|
+
}
|
|
847
|
+
}
|
|
848
|
+
});
|
|
616
849
|
};
|
|
617
|
-
const stopService = () => {
|
|
618
|
-
|
|
619
|
-
poller
|
|
850
|
+
const stopService = async () => {
|
|
851
|
+
serviceGeneration += 1;
|
|
852
|
+
const currentPoller = poller;
|
|
620
853
|
poller = null;
|
|
621
|
-
|
|
622
|
-
|
|
854
|
+
await currentPoller?.stop();
|
|
855
|
+
(0, _alfe_ai_openclaw_plugin_kit.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()
|
|
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
|
-
|
|
869
|
+
serviceGeneration += 1;
|
|
870
|
+
const currentPoller = poller;
|
|
643
871
|
poller = null;
|
|
644
|
-
|
|
645
|
-
|
|
872
|
+
await currentPoller?.stop();
|
|
873
|
+
(0, _alfe_ai_openclaw_plugin_kit.resetActivation)(GCHAT_ACTIVATION_KEY);
|
|
646
874
|
log.info("Google Chat plugin deactivated");
|
|
647
875
|
}
|
|
648
876
|
};
|