@alfe.ai/openclaw-google-chat 0.0.3 → 0.0.4
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/gchat-token.cjs +59 -0
- package/dist/gchat-token.js +59 -0
- package/dist/index.cjs +2 -3
- package/dist/index.d.cts +2 -12
- package/dist/index.d.ts +2 -12
- package/dist/index.js +2 -2
- package/dist/plugin.cjs +2 -28
- package/dist/plugin.d.cts +37 -6
- package/dist/plugin.d.ts +37 -6
- package/dist/plugin.js +1 -29
- package/dist/plugin2.cjs +583 -0
- package/dist/plugin2.js +578 -0
- package/openclaw.plugin.json +4 -11
- package/package.json +7 -2
- package/dist/google-chat-channel.cjs +0 -81
- package/dist/google-chat-channel.d.cts +0 -79
- package/dist/google-chat-channel.d.cts.map +0 -1
- package/dist/google-chat-channel.d.ts +0 -79
- package/dist/google-chat-channel.d.ts.map +0 -1
- package/dist/google-chat-channel.js +0 -78
- package/dist/google-chat-channel.js.map +0 -1
- package/dist/index.d.cts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/plugin.d.cts.map +0 -1
- package/dist/plugin.d.ts.map +0 -1
- package/dist/plugin.js.map +0 -1
package/dist/plugin2.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { resolveConfig } from "@alfe.ai/config";
|
|
4
|
+
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
5
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
//#region src/gchat-state.ts
|
|
8
|
+
/**
|
|
9
|
+
* State persistence — saves poller state to ~/.alfe/state/google-chat-poller.json.
|
|
10
|
+
*
|
|
11
|
+
* Written every 30s (debounced). Read on startup. Defaults to now-5min if
|
|
12
|
+
* no state file exists.
|
|
13
|
+
*/
|
|
14
|
+
const STATE_DIR = join(homedir(), ".alfe", "state");
|
|
15
|
+
const STATE_FILE = join(STATE_DIR, "google-chat-poller.json");
|
|
16
|
+
const SAVE_INTERVAL_MS = 3e4;
|
|
17
|
+
var StateManager = class {
|
|
18
|
+
state = { spaces: {} };
|
|
19
|
+
dirty = false;
|
|
20
|
+
saveTimer = null;
|
|
21
|
+
log;
|
|
22
|
+
constructor(log) {
|
|
23
|
+
this.log = log;
|
|
24
|
+
}
|
|
25
|
+
/** Load state from disk. Returns default state if file doesn't exist. */
|
|
26
|
+
async load() {
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(STATE_FILE, "utf-8");
|
|
29
|
+
this.state = JSON.parse(raw);
|
|
30
|
+
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");
|
|
33
|
+
this.state = { spaces: {} };
|
|
34
|
+
}
|
|
35
|
+
return this.state;
|
|
36
|
+
}
|
|
37
|
+
/** Start the periodic save timer. */
|
|
38
|
+
startAutoSave() {
|
|
39
|
+
if (this.saveTimer) return;
|
|
40
|
+
this.saveTimer = setInterval(() => {
|
|
41
|
+
if (this.dirty) this.flush().catch((err) => {
|
|
42
|
+
this.log.error(`State save failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
43
|
+
});
|
|
44
|
+
}, SAVE_INTERVAL_MS);
|
|
45
|
+
}
|
|
46
|
+
/** Stop the periodic save timer and flush any pending state. */
|
|
47
|
+
async stop() {
|
|
48
|
+
if (this.saveTimer) {
|
|
49
|
+
clearInterval(this.saveTimer);
|
|
50
|
+
this.saveTimer = null;
|
|
51
|
+
}
|
|
52
|
+
if (this.dirty) await this.flush();
|
|
53
|
+
}
|
|
54
|
+
/** Get the last-seen timestamp for a space, or a default 5min ago. */
|
|
55
|
+
getLastSeenTimestamp(spaceName) {
|
|
56
|
+
return this.state.spaces[spaceName]?.lastSeenTimestamp ?? (/* @__PURE__ */ new Date(Date.now() - 300 * 1e3)).toISOString();
|
|
57
|
+
}
|
|
58
|
+
/** Get the agent's userId for a space (if previously resolved). */
|
|
59
|
+
getAgentUserId(spaceName) {
|
|
60
|
+
return this.state.spaces[spaceName]?.agentUserId;
|
|
61
|
+
}
|
|
62
|
+
/** Get the peer's userId for a space (if previously resolved). */
|
|
63
|
+
getPeerUserId(spaceName) {
|
|
64
|
+
return this.state.spaces[spaceName]?.peerUserId;
|
|
65
|
+
}
|
|
66
|
+
/** Update state for a space. */
|
|
67
|
+
updateSpace(spaceName, data) {
|
|
68
|
+
const existing = this.state.spaces[spaceName] ?? {
|
|
69
|
+
lastSeenTimestamp: (/* @__PURE__ */ new Date(Date.now() - 300 * 1e3)).toISOString(),
|
|
70
|
+
agentUserId: "",
|
|
71
|
+
peerUserId: ""
|
|
72
|
+
};
|
|
73
|
+
this.state.spaces[spaceName] = {
|
|
74
|
+
...existing,
|
|
75
|
+
...data
|
|
76
|
+
};
|
|
77
|
+
this.dirty = true;
|
|
78
|
+
}
|
|
79
|
+
/** Remove a space from tracked state. */
|
|
80
|
+
removeSpace(spaceName) {
|
|
81
|
+
delete this.state.spaces[spaceName];
|
|
82
|
+
this.dirty = true;
|
|
83
|
+
}
|
|
84
|
+
/** Write state to disk immediately. */
|
|
85
|
+
async flush() {
|
|
86
|
+
try {
|
|
87
|
+
await mkdir(STATE_DIR, { recursive: true });
|
|
88
|
+
await writeFile(STATE_FILE, JSON.stringify(this.state, null, 2));
|
|
89
|
+
this.dirty = false;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
this.log.error(`Failed to write state: ${err instanceof Error ? err.message : String(err)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/gchat-api.ts
|
|
97
|
+
const CHAT_API = "https://chat.googleapis.com/v1";
|
|
98
|
+
async function request(token, path, options) {
|
|
99
|
+
const url = `${CHAT_API}${path}`;
|
|
100
|
+
const headers = new Headers(options?.headers);
|
|
101
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
102
|
+
headers.set("Content-Type", "application/json");
|
|
103
|
+
const res = await fetch(url, {
|
|
104
|
+
...options,
|
|
105
|
+
headers
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const body = await res.text().catch(() => "");
|
|
109
|
+
throw Object.assign(/* @__PURE__ */ new Error(`Google Chat API error ${String(res.status)}: ${body}`), { status: res.status });
|
|
110
|
+
}
|
|
111
|
+
return await res.json();
|
|
112
|
+
}
|
|
113
|
+
/** List all spaces the user is a member of. */
|
|
114
|
+
async function listSpaces(token, log) {
|
|
115
|
+
const allSpaces = [];
|
|
116
|
+
let pageToken;
|
|
117
|
+
do {
|
|
118
|
+
const params = new URLSearchParams({ pageSize: "100" });
|
|
119
|
+
if (pageToken) params.set("pageToken", pageToken);
|
|
120
|
+
const data = await request(token, `/spaces?${params.toString()}`);
|
|
121
|
+
if (data.spaces) allSpaces.push(...data.spaces);
|
|
122
|
+
pageToken = data.nextPageToken;
|
|
123
|
+
} while (pageToken);
|
|
124
|
+
const dms = allSpaces.filter((s) => s.spaceType === "DIRECT_MESSAGE" && !s.singleUserBotDm);
|
|
125
|
+
log.debug(`Listed ${String(allSpaces.length)} spaces, ${String(dms.length)} are DMs`);
|
|
126
|
+
return dms;
|
|
127
|
+
}
|
|
128
|
+
/** List messages in a space after a given timestamp. */
|
|
129
|
+
async function listMessages(token, spaceName, afterTimestamp, log) {
|
|
130
|
+
const filter = `createTime > "${afterTimestamp}"`;
|
|
131
|
+
const messages = (await request(token, `/${spaceName}/messages?${new URLSearchParams({
|
|
132
|
+
filter,
|
|
133
|
+
orderBy: "createTime asc",
|
|
134
|
+
pageSize: "50"
|
|
135
|
+
}).toString()}`)).messages ?? [];
|
|
136
|
+
if (messages.length > 0) log.debug(`Fetched ${String(messages.length)} new messages from ${spaceName}`);
|
|
137
|
+
return messages;
|
|
138
|
+
}
|
|
139
|
+
/** Send a text message to a space. */
|
|
140
|
+
async function sendMessage(token, spaceName, text) {
|
|
141
|
+
return request(token, `/${spaceName}/messages`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
body: JSON.stringify({ text })
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** List members of a space. */
|
|
147
|
+
async function listMembers(token, spaceName) {
|
|
148
|
+
const allMembers = [];
|
|
149
|
+
let pageToken;
|
|
150
|
+
do {
|
|
151
|
+
const params = new URLSearchParams({ pageSize: "100" });
|
|
152
|
+
if (pageToken) params.set("pageToken", pageToken);
|
|
153
|
+
const data = await request(token, `/${spaceName}/members?${params.toString()}`);
|
|
154
|
+
if (data.memberships) allMembers.push(...data.memberships);
|
|
155
|
+
pageToken = data.nextPageToken;
|
|
156
|
+
} while (pageToken);
|
|
157
|
+
return allMembers;
|
|
158
|
+
}
|
|
159
|
+
/** Fetch the last N messages from a space (for backfill on new space discovery). */
|
|
160
|
+
async function listRecentMessages(token, spaceName, limit) {
|
|
161
|
+
return (await request(token, `/${spaceName}/messages?${new URLSearchParams({ pageSize: String(limit) }).toString()}`)).messages ?? [];
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/gchat-poller.ts
|
|
165
|
+
const INTERVAL_ACTIVE_MS = 1e3;
|
|
166
|
+
const INTERVAL_WARM_MS = 5e3;
|
|
167
|
+
const INTERVAL_IDLE_MS = 15e3;
|
|
168
|
+
const INTERVAL_DORMANT_MS = 3e4;
|
|
169
|
+
const ACTIVE_THRESHOLD_MS = 300 * 1e3;
|
|
170
|
+
const WARM_THRESHOLD_MS = 1800 * 1e3;
|
|
171
|
+
const DORMANT_THRESHOLD_MS = 7200 * 1e3;
|
|
172
|
+
const DISCOVERY_INTERVAL_MS = 3e4;
|
|
173
|
+
const DEDUP_SIZE = 100;
|
|
174
|
+
const BACKFILL_COUNT = 20;
|
|
175
|
+
const MAX_RETRY_DELAY_MS = 6e4;
|
|
176
|
+
const BASE_RETRY_DELAY_MS = 2e3;
|
|
177
|
+
var GChatPoller = class {
|
|
178
|
+
tokenManager;
|
|
179
|
+
dispatchInbound;
|
|
180
|
+
runtime;
|
|
181
|
+
log;
|
|
182
|
+
stateManager;
|
|
183
|
+
agentEmail;
|
|
184
|
+
spaces = /* @__PURE__ */ new Map();
|
|
185
|
+
seenMessages = /* @__PURE__ */ new Map();
|
|
186
|
+
discoveryTimer = null;
|
|
187
|
+
running = false;
|
|
188
|
+
constructor(init) {
|
|
189
|
+
this.tokenManager = init.tokenManager;
|
|
190
|
+
this.dispatchInbound = init.dispatchInbound;
|
|
191
|
+
this.runtime = init.runtime;
|
|
192
|
+
this.log = init.log;
|
|
193
|
+
this.agentEmail = init.agentEmail;
|
|
194
|
+
this.stateManager = new StateManager(init.log);
|
|
195
|
+
}
|
|
196
|
+
/** Start the polling engine. */
|
|
197
|
+
async start() {
|
|
198
|
+
this.running = true;
|
|
199
|
+
await this.stateManager.load();
|
|
200
|
+
this.stateManager.startAutoSave();
|
|
201
|
+
await this.discover();
|
|
202
|
+
this.scheduleDiscovery();
|
|
203
|
+
}
|
|
204
|
+
/** Stop all polling. */
|
|
205
|
+
stop() {
|
|
206
|
+
this.running = false;
|
|
207
|
+
if (this.discoveryTimer) {
|
|
208
|
+
clearTimeout(this.discoveryTimer);
|
|
209
|
+
this.discoveryTimer = null;
|
|
210
|
+
}
|
|
211
|
+
for (const state of this.spaces.values()) if (state.pollTimer) clearTimeout(state.pollTimer);
|
|
212
|
+
this.spaces.clear();
|
|
213
|
+
this.seenMessages.clear();
|
|
214
|
+
this.stateManager.stop().catch((err) => {
|
|
215
|
+
this.log.error(`State save on stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
scheduleDiscovery() {
|
|
219
|
+
if (!this.running) return;
|
|
220
|
+
this.discoveryTimer = setTimeout(() => {
|
|
221
|
+
this.discover().catch((err) => {
|
|
222
|
+
this.handleApiError(err, "space-discovery");
|
|
223
|
+
}).finally(() => {
|
|
224
|
+
this.scheduleDiscovery();
|
|
225
|
+
});
|
|
226
|
+
}, DISCOVERY_INTERVAL_MS);
|
|
227
|
+
}
|
|
228
|
+
async discover() {
|
|
229
|
+
if (!this.running) return;
|
|
230
|
+
const token = await this.tokenManager.getAccessToken();
|
|
231
|
+
const dmSpaces = await listSpaces(token, this.log);
|
|
232
|
+
const currentNames = new Set(dmSpaces.map((s) => s.name));
|
|
233
|
+
for (const space of dmSpaces) if (!this.spaces.has(space.name)) await this.initSpace(space.name, token);
|
|
234
|
+
for (const [name, state] of this.spaces) if (!currentNames.has(name)) {
|
|
235
|
+
this.log.info(`Space removed: ${name}`);
|
|
236
|
+
if (state.pollTimer) clearTimeout(state.pollTimer);
|
|
237
|
+
this.spaces.delete(name);
|
|
238
|
+
this.seenMessages.delete(name);
|
|
239
|
+
this.stateManager.removeSpace(name);
|
|
240
|
+
}
|
|
241
|
+
this.log.debug(`Discovery complete: ${String(this.spaces.size)} active DM spaces`);
|
|
242
|
+
}
|
|
243
|
+
async initSpace(spaceName, token) {
|
|
244
|
+
this.log.info(`New DM space discovered: ${spaceName}`);
|
|
245
|
+
let agentUserId = this.stateManager.getAgentUserId(spaceName) ?? "";
|
|
246
|
+
let peerUserId = this.stateManager.getPeerUserId(spaceName) ?? "";
|
|
247
|
+
let peerDisplayName = null;
|
|
248
|
+
if (!agentUserId || !peerUserId) try {
|
|
249
|
+
const members = await listMembers(token, spaceName);
|
|
250
|
+
const resolved = this.resolveMembers(members);
|
|
251
|
+
agentUserId = resolved.agentUserId;
|
|
252
|
+
peerUserId = resolved.peerUserId;
|
|
253
|
+
peerDisplayName = resolved.peerDisplayName;
|
|
254
|
+
} catch (err) {
|
|
255
|
+
this.log.warn(`Failed to resolve members for ${spaceName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
256
|
+
}
|
|
257
|
+
let lastSeenTimestamp = this.stateManager.getLastSeenTimestamp(spaceName);
|
|
258
|
+
try {
|
|
259
|
+
const recent = await listRecentMessages(token, spaceName, BACKFILL_COUNT);
|
|
260
|
+
if (recent.length > 0) lastSeenTimestamp = recent[recent.length - 1].createTime;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
this.log.warn(`Backfill failed for ${spaceName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
263
|
+
}
|
|
264
|
+
this.stateManager.updateSpace(spaceName, {
|
|
265
|
+
lastSeenTimestamp,
|
|
266
|
+
agentUserId,
|
|
267
|
+
peerUserId
|
|
268
|
+
});
|
|
269
|
+
const state = {
|
|
270
|
+
spaceName,
|
|
271
|
+
lastSeenTimestamp,
|
|
272
|
+
lastMessageAt: 0,
|
|
273
|
+
agentUserId,
|
|
274
|
+
peerUserId,
|
|
275
|
+
peerDisplayName,
|
|
276
|
+
pollTimer: null
|
|
277
|
+
};
|
|
278
|
+
this.spaces.set(spaceName, state);
|
|
279
|
+
this.seenMessages.set(spaceName, /* @__PURE__ */ new Set());
|
|
280
|
+
this.scheduleSpacePoll(state);
|
|
281
|
+
}
|
|
282
|
+
resolveMembers(members) {
|
|
283
|
+
let agentUserId = "";
|
|
284
|
+
let peerUserId = "";
|
|
285
|
+
let peerDisplayName = null;
|
|
286
|
+
for (const m of members) {
|
|
287
|
+
if (m.member.type !== "HUMAN") continue;
|
|
288
|
+
if (this.agentEmail && m.member.displayName.toLowerCase().includes(this.agentEmail.split("@")[0].toLowerCase())) agentUserId = m.member.name;
|
|
289
|
+
else {
|
|
290
|
+
peerUserId = m.member.name;
|
|
291
|
+
peerDisplayName = m.member.displayName || null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (!agentUserId && members.length >= 2) {
|
|
295
|
+
agentUserId = members[0].member.name;
|
|
296
|
+
peerUserId = members[1].member.name;
|
|
297
|
+
peerDisplayName = members[1].member.displayName || null;
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
agentUserId,
|
|
301
|
+
peerUserId,
|
|
302
|
+
peerDisplayName
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
scheduleSpacePoll(state) {
|
|
306
|
+
if (!this.running) return;
|
|
307
|
+
const interval = this.computeInterval(state);
|
|
308
|
+
state.pollTimer = setTimeout(() => {
|
|
309
|
+
this.pollSpace(state).catch((err) => {
|
|
310
|
+
this.handleApiError(err, `poll:${state.spaceName}`);
|
|
311
|
+
}).finally(() => {
|
|
312
|
+
this.scheduleSpacePoll(state);
|
|
313
|
+
});
|
|
314
|
+
}, interval);
|
|
315
|
+
}
|
|
316
|
+
async pollSpace(state) {
|
|
317
|
+
if (!this.running) return;
|
|
318
|
+
const token = await this.tokenManager.getAccessToken();
|
|
319
|
+
let messages;
|
|
320
|
+
try {
|
|
321
|
+
messages = await listMessages(token, state.spaceName, state.lastSeenTimestamp, this.log);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
if (isHttpStatus(err, 401)) {
|
|
324
|
+
this.tokenManager.invalidate();
|
|
325
|
+
let freshToken;
|
|
326
|
+
try {
|
|
327
|
+
freshToken = await this.tokenManager.getAccessToken();
|
|
328
|
+
} catch (refreshErr) {
|
|
329
|
+
this.log.error(`Token refresh failed after 401 — stopping poller: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
|
|
330
|
+
this.stop();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
messages = await listMessages(freshToken, state.spaceName, state.lastSeenTimestamp, this.log);
|
|
334
|
+
} else throw err;
|
|
335
|
+
}
|
|
336
|
+
const seenSet = this.seenMessages.get(state.spaceName) ?? /* @__PURE__ */ new Set();
|
|
337
|
+
for (const msg of messages) {
|
|
338
|
+
if (seenSet.has(msg.name)) continue;
|
|
339
|
+
seenSet.add(msg.name);
|
|
340
|
+
if (seenSet.size > DEDUP_SIZE) {
|
|
341
|
+
const iter = seenSet.values().next();
|
|
342
|
+
if (!iter.done) seenSet.delete(iter.value);
|
|
343
|
+
}
|
|
344
|
+
state.lastSeenTimestamp = msg.createTime;
|
|
345
|
+
this.stateManager.updateSpace(state.spaceName, { lastSeenTimestamp: msg.createTime });
|
|
346
|
+
if (msg.sender.name === state.agentUserId) continue;
|
|
347
|
+
if (!msg.text) continue;
|
|
348
|
+
state.lastMessageAt = Date.now();
|
|
349
|
+
await this.dispatchMessage(msg, state);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async dispatchMessage(message, state) {
|
|
353
|
+
if (!this.dispatchInbound || !this.runtime) {
|
|
354
|
+
this.log.warn("Cannot dispatch — SDK or runtime not available");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const conversationId = `alfe:gchat:${state.spaceName}`;
|
|
358
|
+
const peerLabel = state.peerDisplayName ?? "User";
|
|
359
|
+
this.log.info(`Dispatching message from ${peerLabel} in ${state.spaceName}`);
|
|
360
|
+
try {
|
|
361
|
+
const cfg = this.runtime.config.loadConfig();
|
|
362
|
+
await this.dispatchInbound({
|
|
363
|
+
cfg,
|
|
364
|
+
runtime: { channel: this.runtime.channel },
|
|
365
|
+
channel: "alfe",
|
|
366
|
+
channelLabel: "Google Chat",
|
|
367
|
+
accountId: "default",
|
|
368
|
+
peer: {
|
|
369
|
+
kind: "direct",
|
|
370
|
+
id: `gchat:${state.peerUserId}:conv:${conversationId}`
|
|
371
|
+
},
|
|
372
|
+
senderId: `gchat:${state.peerUserId}`,
|
|
373
|
+
senderAddress: `user:gchat:${state.peerUserId}`,
|
|
374
|
+
recipientAddress: "agent",
|
|
375
|
+
conversationLabel: `[Google Chat] ${peerLabel}`,
|
|
376
|
+
rawBody: message.text,
|
|
377
|
+
messageId: message.name,
|
|
378
|
+
timestamp: new Date(message.createTime).getTime(),
|
|
379
|
+
extraContext: {
|
|
380
|
+
ChannelMode: "gchat",
|
|
381
|
+
ConversationId: conversationId,
|
|
382
|
+
...state.peerDisplayName ? { SenderName: state.peerDisplayName } : {}
|
|
383
|
+
},
|
|
384
|
+
deliver: async (payload) => {
|
|
385
|
+
const text = payload.text ?? "";
|
|
386
|
+
if (!text) return;
|
|
387
|
+
try {
|
|
388
|
+
await sendMessage(await this.tokenManager.getAccessToken(), state.spaceName, text);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
if (isHttpStatus(err, 401)) {
|
|
391
|
+
this.tokenManager.invalidate();
|
|
392
|
+
try {
|
|
393
|
+
await sendMessage(await this.tokenManager.getAccessToken(), state.spaceName, text);
|
|
394
|
+
} catch (refreshErr) {
|
|
395
|
+
this.log.error(`Token refresh failed during reply: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
|
|
396
|
+
throw refreshErr;
|
|
397
|
+
}
|
|
398
|
+
} else throw err;
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
onRecordError: (err) => {
|
|
402
|
+
this.log.error(`Session error: ${err instanceof Error ? err.message : String(err)}`);
|
|
403
|
+
},
|
|
404
|
+
onDispatchError: (err, info) => {
|
|
405
|
+
this.log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
} catch (err) {
|
|
409
|
+
this.log.error(`Failed to dispatch message: ${err instanceof Error ? err.message : String(err)}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
computeInterval(state) {
|
|
413
|
+
if (state.lastMessageAt === 0) return INTERVAL_IDLE_MS;
|
|
414
|
+
const elapsed = Date.now() - state.lastMessageAt;
|
|
415
|
+
if (elapsed < ACTIVE_THRESHOLD_MS) return INTERVAL_ACTIVE_MS;
|
|
416
|
+
if (elapsed < WARM_THRESHOLD_MS) return INTERVAL_WARM_MS;
|
|
417
|
+
if (elapsed < DORMANT_THRESHOLD_MS) return INTERVAL_IDLE_MS;
|
|
418
|
+
return INTERVAL_DORMANT_MS;
|
|
419
|
+
}
|
|
420
|
+
retryCount = 0;
|
|
421
|
+
handleApiError(err, context) {
|
|
422
|
+
if (isHttpStatus(err, 403) || isTokenRevokedError(err)) {
|
|
423
|
+
this.log.error(`Token revoked or forbidden (${context}) — stopping poller`);
|
|
424
|
+
this.stop();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (isHttpStatus(err, 429)) {
|
|
428
|
+
this.retryCount = Math.min(this.retryCount + 1, 5);
|
|
429
|
+
const delay = Math.min(BASE_RETRY_DELAY_MS * Math.pow(2, this.retryCount), MAX_RETRY_DELAY_MS);
|
|
430
|
+
this.log.warn(`Rate limited (${context}) — backing off ${String(delay)}ms`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (isHttpStatus(err, 404)) {
|
|
434
|
+
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`);
|
|
435
|
+
this.stop();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.retryCount = 0;
|
|
439
|
+
this.log.error(`API error in ${context}: ${err instanceof Error ? err.message : String(err)}`);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
function isHttpStatus(err, status) {
|
|
443
|
+
return typeof err === "object" && err !== null && "status" in err && err.status === status;
|
|
444
|
+
}
|
|
445
|
+
function isTokenRevokedError(err) {
|
|
446
|
+
if (!(err instanceof Error)) return false;
|
|
447
|
+
return err.message.includes("invalid_grant") || err.message.includes("Token has been revoked");
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
//#region src/plugin.ts
|
|
451
|
+
/**
|
|
452
|
+
* @alfe.ai/openclaw-google-chat — OpenClaw Google Chat channel plugin.
|
|
453
|
+
*
|
|
454
|
+
* Polls Google Chat DM spaces and dispatches user messages through
|
|
455
|
+
* OpenClaw's auto-reply pipeline via dispatchInboundDirectDmWithRuntime().
|
|
456
|
+
* Replies are sent back to Google Chat as the connected user.
|
|
457
|
+
*
|
|
458
|
+
* Architecture:
|
|
459
|
+
* Google Chat API (poll every 1-30s)
|
|
460
|
+
* → GChatPoller
|
|
461
|
+
* → dispatchInboundDirectDmWithRuntime() → Agent pipeline
|
|
462
|
+
* ← deliver() callback → Google Chat API (reply as user)
|
|
463
|
+
*/
|
|
464
|
+
const require = createRequire(import.meta.url);
|
|
465
|
+
const pkg = require("../package.json");
|
|
466
|
+
let dispatchInbound = null;
|
|
467
|
+
/**
|
|
468
|
+
* Resolve OpenClaw SDK from the running process.
|
|
469
|
+
* Copied from @alfe.ai/openclaw-chat's resolution strategy.
|
|
470
|
+
*/
|
|
471
|
+
function resolveOpenClawSdk(log) {
|
|
472
|
+
const anchors = [require.main?.filename, process.argv[1]].filter(Boolean);
|
|
473
|
+
for (const anchor of anchors) try {
|
|
474
|
+
const channelInbound = createRequire(anchor)("openclaw/plugin-sdk/channel-inbound");
|
|
475
|
+
if (channelInbound.dispatchInboundDirectDmWithRuntime) {
|
|
476
|
+
dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
|
|
477
|
+
log.info(`Resolved OpenClaw SDK from ${anchor}`);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
} catch {}
|
|
481
|
+
try {
|
|
482
|
+
const derivedPath = join(resolve(dirname(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
|
|
483
|
+
const channelInbound = createRequire(derivedPath)("openclaw/plugin-sdk/channel-inbound");
|
|
484
|
+
if (channelInbound.dispatchInboundDirectDmWithRuntime) {
|
|
485
|
+
dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
|
|
486
|
+
log.info(`Resolved OpenClaw SDK from ${derivedPath}`);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
} catch {}
|
|
490
|
+
log.warn("OpenClaw SDK not resolvable — Google Chat dispatch will not work");
|
|
491
|
+
}
|
|
492
|
+
let pluginRuntime = null;
|
|
493
|
+
let poller = null;
|
|
494
|
+
const plugin = {
|
|
495
|
+
id: "@alfe.ai/openclaw-google-chat",
|
|
496
|
+
name: "Google Chat",
|
|
497
|
+
description: "Google Chat DM polling — receive and reply via connected Google account",
|
|
498
|
+
version: pkg.version,
|
|
499
|
+
activate(api) {
|
|
500
|
+
const log = api.logger;
|
|
501
|
+
if (globalThis.__alfeGoogleChatActivated) return;
|
|
502
|
+
const startService = async () => {
|
|
503
|
+
if (globalThis.__alfeGoogleChatActivated) {
|
|
504
|
+
log.debug("Google Chat plugin already activated — skipping duplicate");
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
globalThis.__alfeGoogleChatActivated = true;
|
|
508
|
+
log.info("Google Chat plugin starting...");
|
|
509
|
+
resolveOpenClawSdk(log);
|
|
510
|
+
pluginRuntime = api.runtime ?? null;
|
|
511
|
+
let client;
|
|
512
|
+
try {
|
|
513
|
+
const cfg = resolveConfig();
|
|
514
|
+
client = new AgentApiClient({
|
|
515
|
+
apiKey: cfg.apiKey,
|
|
516
|
+
apiUrl: cfg.apiUrl
|
|
517
|
+
});
|
|
518
|
+
} catch (err) {
|
|
519
|
+
log.error(`Failed to resolve config: ${err instanceof Error ? err.message : String(err)}`);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
let creds;
|
|
523
|
+
try {
|
|
524
|
+
creds = await client.getGoogleChatCredentials();
|
|
525
|
+
} catch {
|
|
526
|
+
log.info("Google Chat not connected — polling disabled");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
poller = new GChatPoller({
|
|
530
|
+
tokenManager: new (await (import("./gchat-token.js"))).TokenManager({
|
|
531
|
+
refreshToken: creds.refreshToken,
|
|
532
|
+
clientId: creds.clientId,
|
|
533
|
+
clientSecret: creds.clientSecret
|
|
534
|
+
}, log),
|
|
535
|
+
dispatchInbound,
|
|
536
|
+
runtime: pluginRuntime,
|
|
537
|
+
log,
|
|
538
|
+
agentEmail: creds.email
|
|
539
|
+
});
|
|
540
|
+
await poller.start();
|
|
541
|
+
log.info(`Google Chat poller started (account: ${creds.email})`);
|
|
542
|
+
};
|
|
543
|
+
const stopService = () => {
|
|
544
|
+
globalThis.__alfeGoogleChatActivated = false;
|
|
545
|
+
poller?.stop();
|
|
546
|
+
poller = null;
|
|
547
|
+
pluginRuntime = null;
|
|
548
|
+
dispatchInbound = null;
|
|
549
|
+
log.info("Google Chat plugin stopped");
|
|
550
|
+
};
|
|
551
|
+
if (api.registerService) api.registerService({
|
|
552
|
+
id: "google-chat-poller",
|
|
553
|
+
start: () => {
|
|
554
|
+
startService().catch((err) => {
|
|
555
|
+
log.error(`Google Chat service start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
556
|
+
});
|
|
557
|
+
},
|
|
558
|
+
stop: () => {
|
|
559
|
+
stopService();
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
else startService().catch((err) => {
|
|
563
|
+
log.error(`Failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
564
|
+
});
|
|
565
|
+
log.info("Google Chat plugin registered");
|
|
566
|
+
},
|
|
567
|
+
deactivate(api) {
|
|
568
|
+
globalThis.__alfeGoogleChatActivated = false;
|
|
569
|
+
const log = api.logger;
|
|
570
|
+
poller?.stop();
|
|
571
|
+
poller = null;
|
|
572
|
+
pluginRuntime = null;
|
|
573
|
+
dispatchInbound = null;
|
|
574
|
+
log.info("Google Chat plugin deactivated");
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
//#endregion
|
|
578
|
+
export { plugin as t };
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "@alfe.ai/openclaw-google-chat",
|
|
3
|
-
"
|
|
3
|
+
"name": "Google Chat",
|
|
4
|
+
"description": "Google Chat DM polling — receive and reply to direct messages via connected Google account",
|
|
5
|
+
"entry": "./dist/plugin.js",
|
|
4
6
|
"configSchema": {
|
|
5
7
|
"type": "object",
|
|
6
8
|
"additionalProperties": false,
|
|
7
|
-
"properties": {
|
|
8
|
-
"workspaceDomain": {
|
|
9
|
-
"type": "string",
|
|
10
|
-
"description": "Connected Google Workspace domain"
|
|
11
|
-
},
|
|
12
|
-
"email": {
|
|
13
|
-
"type": "string",
|
|
14
|
-
"description": "Google account email for this agent"
|
|
15
|
-
}
|
|
16
|
-
}
|
|
9
|
+
"properties": {}
|
|
17
10
|
}
|
|
18
11
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfe.ai/openclaw-google-chat",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "OpenClaw Google Chat
|
|
3
|
+
"version": "0.0.4",
|
|
4
|
+
"description": "OpenClaw Google Chat plugin — DM polling and auto-reply via connected Google account",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/plugin.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -26,6 +26,10 @@
|
|
|
26
26
|
"dist",
|
|
27
27
|
"openclaw.plugin.json"
|
|
28
28
|
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@alfe.ai/agent-api-client": "^0.0.12",
|
|
31
|
+
"@alfe.ai/config": "^0.0.8"
|
|
32
|
+
},
|
|
29
33
|
"peerDependencies": {
|
|
30
34
|
"openclaw": ">=2026.3.0"
|
|
31
35
|
},
|
|
@@ -38,6 +42,7 @@
|
|
|
38
42
|
"scripts": {
|
|
39
43
|
"build": "tsdown",
|
|
40
44
|
"dev": "tsdown --watch",
|
|
45
|
+
"test": "vitest run --passWithNoTests",
|
|
41
46
|
"typecheck": "tsc --noEmit",
|
|
42
47
|
"lint": "eslint ."
|
|
43
48
|
}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
//#region src/google-chat-channel.ts
|
|
2
|
-
/**
|
|
3
|
-
* Google Chat channel registration for OpenClaw.
|
|
4
|
-
*
|
|
5
|
-
* Follows the same ChannelPlugin shape as alfe-channel.ts in openclaw-chat.
|
|
6
|
-
* This is metadata-only — no transport. The backend service handles all messaging.
|
|
7
|
-
*
|
|
8
|
-
* All config methods return sensible defaults because Google Chat is entirely
|
|
9
|
-
* backend-managed — there is no local openclaw.yaml config for this channel.
|
|
10
|
-
*/
|
|
11
|
-
const DEFAULT_ACCOUNT_ID = "default";
|
|
12
|
-
function createGoogleChatChannelPlugin() {
|
|
13
|
-
return {
|
|
14
|
-
id: "google-chat",
|
|
15
|
-
meta: {
|
|
16
|
-
id: "google-chat",
|
|
17
|
-
label: "Google Chat",
|
|
18
|
-
description: "Google Workspace Chat — spaces, DMs, and threads",
|
|
19
|
-
systemImage: "google-chat"
|
|
20
|
-
},
|
|
21
|
-
capabilities: {
|
|
22
|
-
chatTypes: ["direct", "group"],
|
|
23
|
-
threads: true,
|
|
24
|
-
reactions: true,
|
|
25
|
-
edit: false,
|
|
26
|
-
unsend: false,
|
|
27
|
-
reply: true,
|
|
28
|
-
media: false,
|
|
29
|
-
nativeCommands: false
|
|
30
|
-
},
|
|
31
|
-
outbound: { deliveryMode: "gateway" },
|
|
32
|
-
config: {
|
|
33
|
-
listAccountIds() {
|
|
34
|
-
return [DEFAULT_ACCOUNT_ID];
|
|
35
|
-
},
|
|
36
|
-
resolveAccount(_cfg, accountId) {
|
|
37
|
-
return {
|
|
38
|
-
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
|
39
|
-
enabled: true,
|
|
40
|
-
allowFrom: []
|
|
41
|
-
};
|
|
42
|
-
},
|
|
43
|
-
defaultAccountId() {
|
|
44
|
-
return DEFAULT_ACCOUNT_ID;
|
|
45
|
-
},
|
|
46
|
-
isEnabled(account) {
|
|
47
|
-
return account.enabled;
|
|
48
|
-
},
|
|
49
|
-
isConfigured() {
|
|
50
|
-
return true;
|
|
51
|
-
},
|
|
52
|
-
describeAccount(account) {
|
|
53
|
-
return {
|
|
54
|
-
accountId: account.accountId,
|
|
55
|
-
enabled: account.enabled,
|
|
56
|
-
configured: true,
|
|
57
|
-
dmPolicy: account.dmPolicy
|
|
58
|
-
};
|
|
59
|
-
},
|
|
60
|
-
resolveAllowFrom() {
|
|
61
|
-
return [];
|
|
62
|
-
},
|
|
63
|
-
resolveDefaultTo() {}
|
|
64
|
-
},
|
|
65
|
-
setup: {
|
|
66
|
-
resolveAccountId(params) {
|
|
67
|
-
return params.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
68
|
-
},
|
|
69
|
-
applyAccountConfig(params) {
|
|
70
|
-
return params.cfg;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
//#endregion
|
|
76
|
-
Object.defineProperty(exports, "createGoogleChatChannelPlugin", {
|
|
77
|
-
enumerable: true,
|
|
78
|
-
get: function() {
|
|
79
|
-
return createGoogleChatChannelPlugin;
|
|
80
|
-
}
|
|
81
|
-
});
|