@chrysb/alphaclaw 0.3.5-beta.1 → 0.4.1-beta.0
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/bin/alphaclaw.js +1 -31
- package/lib/public/assets/icons/google_icon.svg +8 -0
- package/lib/public/css/explorer.css +53 -0
- package/lib/public/css/shell.css +21 -19
- package/lib/public/css/theme.css +17 -0
- package/lib/public/js/app.js +205 -109
- package/lib/public/js/components/credentials-modal.js +36 -8
- package/lib/public/js/components/file-tree.js +212 -22
- package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
- package/lib/public/js/components/file-viewer/index.js +47 -6
- package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
- package/lib/public/js/components/file-viewer/status-banners.js +11 -6
- package/lib/public/js/components/file-viewer/toolbar.js +56 -1
- package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
- package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
- package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
- package/lib/public/js/components/file-viewer/use-file-viewer.js +142 -15
- package/lib/public/js/components/google/account-row.js +131 -0
- package/lib/public/js/components/google/add-account-modal.js +93 -0
- package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
- package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
- package/lib/public/js/components/google/index.js +553 -0
- package/lib/public/js/components/google/use-gmail-watch.js +140 -0
- package/lib/public/js/components/google/use-google-accounts.js +41 -0
- package/lib/public/js/components/icons.js +26 -0
- package/lib/public/js/components/scope-picker.js +1 -1
- package/lib/public/js/components/sidebar-git-panel.js +48 -20
- package/lib/public/js/components/sidebar.js +93 -75
- package/lib/public/js/components/toast.js +11 -7
- package/lib/public/js/components/usage-tab/constants.js +31 -0
- package/lib/public/js/components/usage-tab/formatters.js +24 -0
- package/lib/public/js/components/usage-tab/index.js +72 -0
- package/lib/public/js/components/usage-tab/overview-section.js +147 -0
- package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
- package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
- package/lib/public/js/components/webhooks.js +182 -129
- package/lib/public/js/lib/api.js +178 -9
- package/lib/public/js/lib/browse-file-policies.js +29 -11
- package/lib/public/js/lib/format.js +71 -0
- package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
- package/lib/public/shared/browse-file-policies.json +13 -0
- package/lib/server/constants.js +47 -7
- package/lib/server/gmail-push.js +109 -0
- package/lib/server/gmail-serve.js +254 -0
- package/lib/server/gmail-watch.js +725 -0
- package/lib/server/google-state.js +317 -0
- package/lib/server/helpers.js +17 -11
- package/lib/server/internal-files-migration.js +31 -3
- package/lib/server/onboarding/github.js +21 -2
- package/lib/server/onboarding/index.js +1 -3
- package/lib/server/onboarding/openclaw.js +3 -0
- package/lib/server/onboarding/workspace.js +40 -0
- package/lib/server/routes/browse/index.js +90 -2
- package/lib/server/routes/gmail.js +128 -0
- package/lib/server/routes/google.js +433 -213
- package/lib/server/routes/system.js +107 -0
- package/lib/server/routes/usage.js +29 -2
- package/lib/server/routes/webhooks.js +52 -17
- package/lib/server/usage-db.js +283 -15
- package/lib/server/watchdog.js +66 -0
- package/lib/server/webhook-middleware.js +99 -1
- package/lib/server/webhooks.js +214 -65
- package/lib/server.js +27 -0
- package/lib/setup/gitignore +6 -0
- package/lib/setup/hourly-git-sync.sh +29 -2
- package/package.json +1 -1
- package/lib/public/js/components/google.js +0 -228
- package/lib/public/js/components/usage-tab.js +0 -531
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const {
|
|
3
|
+
readGoogleState,
|
|
4
|
+
writeGoogleState,
|
|
5
|
+
listGoogleAccounts,
|
|
6
|
+
getGoogleAccountById,
|
|
7
|
+
getGoogleAccountByEmail,
|
|
8
|
+
getGmailPushConfig,
|
|
9
|
+
setGmailPushConfig,
|
|
10
|
+
getAccountGmailWatch,
|
|
11
|
+
setAccountGmailWatch,
|
|
12
|
+
listWatchEnabledAccounts,
|
|
13
|
+
generatePushToken,
|
|
14
|
+
allocateServePort,
|
|
15
|
+
} = require("./google-state");
|
|
16
|
+
const { createGmailServeManager } = require("./gmail-serve");
|
|
17
|
+
const { createWebhook } = require("./webhooks");
|
|
18
|
+
|
|
19
|
+
const quoteShellArg = (value) =>
|
|
20
|
+
`"${String(value || "").replace(/(["\\$`])/g, "\\$1")}"`;
|
|
21
|
+
|
|
22
|
+
const parseJsonMaybe = (raw) => {
|
|
23
|
+
const text = String(raw || "").trim();
|
|
24
|
+
if (!text) return null;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(text);
|
|
27
|
+
} catch {}
|
|
28
|
+
const firstBrace = text.indexOf("{");
|
|
29
|
+
const lastBrace = text.lastIndexOf("}");
|
|
30
|
+
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(text.slice(firstBrace, lastBrace + 1));
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const parseExpirationFromOutput = (raw) => {
|
|
39
|
+
const parsed = parseJsonMaybe(raw);
|
|
40
|
+
if (parsed?.expiration) {
|
|
41
|
+
const numeric = Number.parseInt(String(parsed.expiration), 10);
|
|
42
|
+
if (Number.isFinite(numeric) && numeric > 0) return numeric;
|
|
43
|
+
}
|
|
44
|
+
const text = String(raw || "");
|
|
45
|
+
const epochMatch = text.match(/"expiration"\s*:\s*"?(\d{10,})"?/i);
|
|
46
|
+
if (epochMatch?.[1]) {
|
|
47
|
+
const numeric = Number.parseInt(epochMatch[1], 10);
|
|
48
|
+
if (Number.isFinite(numeric) && numeric > 0) return numeric;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const createTopicNameForClient = (client = "default") => {
|
|
54
|
+
const normalizedClient = String(client || "default")
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
58
|
+
.replace(/-+/g, "-")
|
|
59
|
+
.replace(/^-|-$/g, "");
|
|
60
|
+
if (!normalizedClient || normalizedClient === "default") {
|
|
61
|
+
return "gog-gmail-watch";
|
|
62
|
+
}
|
|
63
|
+
return `gog-gmail-watch-${normalizedClient}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const createSubscriptionNameForClient = (client = "default") =>
|
|
67
|
+
`${createTopicNameForClient(client)}-push`;
|
|
68
|
+
|
|
69
|
+
const parseTopicName = (topicPath = "") => {
|
|
70
|
+
const match = String(topicPath || "").match(/\/topics\/([^/]+)$/);
|
|
71
|
+
return match?.[1] ? String(match[1]) : "";
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const parseProjectIdFromTopicPath = (topicPath = "") => {
|
|
75
|
+
const match = String(topicPath || "").match(/^projects\/([^/]+)\/topics\/[^/]+$/);
|
|
76
|
+
return match?.[1] ? String(match[1]) : "";
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const ensureTopicPathForClient = ({
|
|
80
|
+
state,
|
|
81
|
+
client,
|
|
82
|
+
readGoogleCredentials,
|
|
83
|
+
projectIdOverride = "",
|
|
84
|
+
}) => {
|
|
85
|
+
const normalizedClient = String(client || "default").trim() || "default";
|
|
86
|
+
const push = getGmailPushConfig(state);
|
|
87
|
+
const existingTopic = String(push.topics?.[normalizedClient] || "").trim();
|
|
88
|
+
if (existingTopic) {
|
|
89
|
+
return { state, topicPath: existingTopic };
|
|
90
|
+
}
|
|
91
|
+
const credentials = readGoogleCredentials(normalizedClient);
|
|
92
|
+
const projectId =
|
|
93
|
+
String(projectIdOverride || "").trim() ||
|
|
94
|
+
String(credentials?.projectId || "").trim();
|
|
95
|
+
if (!projectId) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Could not detect GCP project_id for client "${normalizedClient}". Save Google credentials first.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const topicName = createTopicNameForClient(normalizedClient);
|
|
101
|
+
const topicPath = `projects/${projectId}/topics/${topicName}`;
|
|
102
|
+
const updated = setGmailPushConfig({
|
|
103
|
+
state,
|
|
104
|
+
config: {
|
|
105
|
+
topics: {
|
|
106
|
+
[normalizedClient]: topicPath,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
state: updated.state,
|
|
112
|
+
topicPath,
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const createGmailWatchService = ({
|
|
117
|
+
fs,
|
|
118
|
+
constants,
|
|
119
|
+
gogCmd,
|
|
120
|
+
getBaseUrl,
|
|
121
|
+
readGoogleCredentials,
|
|
122
|
+
readEnvFile,
|
|
123
|
+
writeEnvFile,
|
|
124
|
+
reloadEnv,
|
|
125
|
+
restartRequiredState,
|
|
126
|
+
}) => {
|
|
127
|
+
const ensureAccountClientMappings = ({ state }) => {
|
|
128
|
+
const configDir = String(constants.GOG_CONFIG_DIR || "").trim();
|
|
129
|
+
if (!configDir) return;
|
|
130
|
+
const configPath = path.join(configDir, "config.json");
|
|
131
|
+
let currentConfig = {};
|
|
132
|
+
try {
|
|
133
|
+
if (fs.existsSync(configPath)) {
|
|
134
|
+
const raw = String(fs.readFileSync(configPath, "utf8") || "").trim();
|
|
135
|
+
if (raw) {
|
|
136
|
+
const parsed = JSON.parse(raw);
|
|
137
|
+
if (parsed && typeof parsed === "object") {
|
|
138
|
+
currentConfig = parsed;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {}
|
|
143
|
+
|
|
144
|
+
const nextAccountClients = {
|
|
145
|
+
...(currentConfig.account_clients &&
|
|
146
|
+
typeof currentConfig.account_clients === "object" &&
|
|
147
|
+
!Array.isArray(currentConfig.account_clients)
|
|
148
|
+
? currentConfig.account_clients
|
|
149
|
+
: {}),
|
|
150
|
+
};
|
|
151
|
+
for (const account of listGoogleAccounts(state)) {
|
|
152
|
+
const email = String(account?.email || "").trim().toLowerCase();
|
|
153
|
+
const client = String(account?.client || "default").trim() || "default";
|
|
154
|
+
if (!email) continue;
|
|
155
|
+
nextAccountClients[email] = client;
|
|
156
|
+
}
|
|
157
|
+
const nextConfig = {
|
|
158
|
+
...currentConfig,
|
|
159
|
+
account_clients: nextAccountClients,
|
|
160
|
+
};
|
|
161
|
+
const previousSerialized = JSON.stringify(currentConfig);
|
|
162
|
+
const nextSerialized = JSON.stringify(nextConfig);
|
|
163
|
+
if (previousSerialized === nextSerialized) return;
|
|
164
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
165
|
+
fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const readState = () =>
|
|
169
|
+
readGoogleState({
|
|
170
|
+
fs,
|
|
171
|
+
statePath: constants.GOG_STATE_PATH,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const saveState = (state) => {
|
|
175
|
+
ensureAccountClientMappings({ state });
|
|
176
|
+
writeGoogleState({
|
|
177
|
+
fs,
|
|
178
|
+
statePath: constants.GOG_STATE_PATH,
|
|
179
|
+
state,
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const markRestartRequired = (source = "gmail-watch") => {
|
|
184
|
+
try {
|
|
185
|
+
restartRequiredState?.markRequired?.(source);
|
|
186
|
+
} catch {}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const ensurePushToken = ({ state, forceRegenerate = false }) => {
|
|
190
|
+
const current = getGmailPushConfig(state);
|
|
191
|
+
if (current.token && !forceRegenerate) {
|
|
192
|
+
return { state, token: current.token };
|
|
193
|
+
}
|
|
194
|
+
const token = generatePushToken();
|
|
195
|
+
const updated = setGmailPushConfig({
|
|
196
|
+
state,
|
|
197
|
+
config: {
|
|
198
|
+
...current,
|
|
199
|
+
token,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
return { state: updated.state, token };
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const ensureWebhookToken = () => {
|
|
206
|
+
const existing = String(
|
|
207
|
+
process.env.OPENCLAW_HOOKS_TOKEN || process.env.WEBHOOK_TOKEN || "",
|
|
208
|
+
).trim();
|
|
209
|
+
if (existing) return { token: existing, changed: false };
|
|
210
|
+
const vars = readEnvFile();
|
|
211
|
+
const tokenFromOpenclawHooks = String(
|
|
212
|
+
vars.find((entry) => entry.key === "OPENCLAW_HOOKS_TOKEN")?.value || "",
|
|
213
|
+
).trim();
|
|
214
|
+
const tokenFromLegacyWebhook = String(
|
|
215
|
+
vars.find((entry) => entry.key === "WEBHOOK_TOKEN")?.value || "",
|
|
216
|
+
).trim();
|
|
217
|
+
const tokenFromFile = tokenFromOpenclawHooks || tokenFromLegacyWebhook;
|
|
218
|
+
if (tokenFromFile) {
|
|
219
|
+
process.env.OPENCLAW_HOOKS_TOKEN = tokenFromFile;
|
|
220
|
+
if (!process.env.WEBHOOK_TOKEN) {
|
|
221
|
+
process.env.WEBHOOK_TOKEN = tokenFromFile;
|
|
222
|
+
}
|
|
223
|
+
if (!tokenFromOpenclawHooks) {
|
|
224
|
+
const nextVars = vars.filter(
|
|
225
|
+
(entry) => entry.key !== "OPENCLAW_HOOKS_TOKEN",
|
|
226
|
+
);
|
|
227
|
+
nextVars.push({ key: "OPENCLAW_HOOKS_TOKEN", value: tokenFromFile });
|
|
228
|
+
writeEnvFile(nextVars);
|
|
229
|
+
reloadEnv();
|
|
230
|
+
return { token: tokenFromFile, changed: true };
|
|
231
|
+
}
|
|
232
|
+
return { token: tokenFromFile, changed: false };
|
|
233
|
+
}
|
|
234
|
+
const token = generatePushToken();
|
|
235
|
+
const nextVars = vars.filter(
|
|
236
|
+
(entry) => entry.key !== "OPENCLAW_HOOKS_TOKEN",
|
|
237
|
+
);
|
|
238
|
+
nextVars.push({ key: "OPENCLAW_HOOKS_TOKEN", value: token });
|
|
239
|
+
writeEnvFile(nextVars);
|
|
240
|
+
reloadEnv();
|
|
241
|
+
return { token, changed: true };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const ensureHooksPreset = () => {
|
|
245
|
+
const configPath = path.join(constants.OPENCLAW_DIR, "openclaw.json");
|
|
246
|
+
if (!fs.existsSync(configPath)) {
|
|
247
|
+
throw new Error("openclaw.json not found. Complete onboarding first.");
|
|
248
|
+
}
|
|
249
|
+
const gmailTransformModulePath = "gmail/gmail-transform.mjs";
|
|
250
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
251
|
+
let changed = false;
|
|
252
|
+
if (!cfg.hooks || typeof cfg.hooks !== "object") {
|
|
253
|
+
cfg.hooks = {};
|
|
254
|
+
changed = true;
|
|
255
|
+
}
|
|
256
|
+
if (cfg.hooks.enabled !== true) {
|
|
257
|
+
cfg.hooks.enabled = true;
|
|
258
|
+
changed = true;
|
|
259
|
+
}
|
|
260
|
+
if (typeof cfg.hooks.token !== "string" || !cfg.hooks.token.trim()) {
|
|
261
|
+
cfg.hooks.token = "${OPENCLAW_HOOKS_TOKEN}";
|
|
262
|
+
changed = true;
|
|
263
|
+
}
|
|
264
|
+
if (!Array.isArray(cfg.hooks.presets)) {
|
|
265
|
+
cfg.hooks.presets = [];
|
|
266
|
+
changed = true;
|
|
267
|
+
}
|
|
268
|
+
if (!cfg.hooks.presets.includes("gmail")) {
|
|
269
|
+
cfg.hooks.presets = [...cfg.hooks.presets, "gmail"];
|
|
270
|
+
changed = true;
|
|
271
|
+
}
|
|
272
|
+
if (changed) {
|
|
273
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
|
|
274
|
+
}
|
|
275
|
+
const webhookBefore = fs.readFileSync(configPath, "utf8");
|
|
276
|
+
createWebhook({
|
|
277
|
+
fs,
|
|
278
|
+
constants,
|
|
279
|
+
name: "gmail",
|
|
280
|
+
upsert: true,
|
|
281
|
+
allowManagedName: true,
|
|
282
|
+
mapping: {
|
|
283
|
+
action: "agent",
|
|
284
|
+
name: "Gmail",
|
|
285
|
+
wakeMode: "now",
|
|
286
|
+
transform: { module: gmailTransformModulePath },
|
|
287
|
+
},
|
|
288
|
+
transformSource: [
|
|
289
|
+
"export default async function transform(payload) {",
|
|
290
|
+
" const data = payload?.payload || payload || {};",
|
|
291
|
+
" const messages = Array.isArray(data.messages) ? data.messages : [];",
|
|
292
|
+
" const first = messages[0] || {};",
|
|
293
|
+
" const from = String(first.from || \"unknown sender\").trim();",
|
|
294
|
+
" const subject = String(first.subject || \"(no subject)\").trim();",
|
|
295
|
+
" const snippet = String(first.snippet || \"\").trim();",
|
|
296
|
+
" return {",
|
|
297
|
+
" message: `New email from ${from}\\nSubject: ${subject}\\n${snippet}`.trim(),",
|
|
298
|
+
" messages,",
|
|
299
|
+
' name: "Gmail",',
|
|
300
|
+
' wakeMode: "now",',
|
|
301
|
+
" };",
|
|
302
|
+
"}",
|
|
303
|
+
"",
|
|
304
|
+
].join("\n"),
|
|
305
|
+
});
|
|
306
|
+
const webhookAfter = fs.readFileSync(configPath, "utf8");
|
|
307
|
+
if (webhookBefore !== webhookAfter) {
|
|
308
|
+
changed = true;
|
|
309
|
+
}
|
|
310
|
+
return { changed };
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const ensureHookWiring = () => {
|
|
314
|
+
const webhook = ensureWebhookToken();
|
|
315
|
+
const hooks = ensureHooksPreset();
|
|
316
|
+
const changed = webhook.changed || hooks.changed;
|
|
317
|
+
if (changed) markRestartRequired("gmail-watch");
|
|
318
|
+
return { webhookToken: webhook.token, changed };
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const runGogForAccount = async ({ account, command, quiet = true }) => {
|
|
322
|
+
const client = String(account?.client || "default").trim() || "default";
|
|
323
|
+
const prefix = client === "default" ? "" : `--client ${quoteShellArg(client)} `;
|
|
324
|
+
return await gogCmd(`${prefix}${command}`, { quiet });
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
let serviceRef = null;
|
|
328
|
+
const serveManager = createGmailServeManager({
|
|
329
|
+
constants,
|
|
330
|
+
onServeExit: (payload) => {
|
|
331
|
+
const accountId = String(payload?.accountId || "").trim();
|
|
332
|
+
if (!accountId) return;
|
|
333
|
+
setTimeout(async () => {
|
|
334
|
+
try {
|
|
335
|
+
const state = readState();
|
|
336
|
+
const account = getGoogleAccountById(state, accountId);
|
|
337
|
+
const watch = getAccountGmailWatch(account || {});
|
|
338
|
+
if (!account || !watch.enabled || !watch.port) return;
|
|
339
|
+
const token = String(
|
|
340
|
+
process.env.OPENCLAW_HOOKS_TOKEN || process.env.WEBHOOK_TOKEN || "",
|
|
341
|
+
).trim();
|
|
342
|
+
if (!token) return;
|
|
343
|
+
const status = await serveManager.startServe({
|
|
344
|
+
account,
|
|
345
|
+
port: watch.port,
|
|
346
|
+
webhookToken: token,
|
|
347
|
+
});
|
|
348
|
+
const updated = setAccountGmailWatch({
|
|
349
|
+
state,
|
|
350
|
+
accountId,
|
|
351
|
+
watch: {
|
|
352
|
+
pid: status.pid || null,
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
saveState(updated.state);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
console.error("[alphaclaw] Gmail serve auto-restart failed:", err);
|
|
358
|
+
}
|
|
359
|
+
}, 5000);
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const buildClientConfig = ({ state, client, baseUrl }) => {
|
|
364
|
+
const normalizedClient = String(client || "default").trim() || "default";
|
|
365
|
+
const push = getGmailPushConfig(state);
|
|
366
|
+
const topicPath = String(push.topics?.[normalizedClient] || "").trim();
|
|
367
|
+
const credentials = readGoogleCredentials(normalizedClient);
|
|
368
|
+
const projectId =
|
|
369
|
+
String(credentials?.projectId || "").trim() ||
|
|
370
|
+
parseProjectIdFromTopicPath(topicPath);
|
|
371
|
+
const topicName = parseTopicName(topicPath) || createTopicNameForClient(normalizedClient);
|
|
372
|
+
const subscriptionName = createSubscriptionNameForClient(normalizedClient);
|
|
373
|
+
const pushEndpoint = `${baseUrl}/gmail-pubsub?token=${encodeURIComponent(
|
|
374
|
+
String(push.token || ""),
|
|
375
|
+
)}`;
|
|
376
|
+
const commands =
|
|
377
|
+
projectId && push.token
|
|
378
|
+
? {
|
|
379
|
+
enableApis: `gcloud --project ${projectId} services enable gmail.googleapis.com pubsub.googleapis.com`,
|
|
380
|
+
createTopic: `gcloud --project ${projectId} pubsub topics create ${topicName}`,
|
|
381
|
+
grantPublisher: `gcloud --project ${projectId} pubsub topics add-iam-policy-binding ${topicName} --member=serviceAccount:gmail-api-push@system.gserviceaccount.com --role=roles/pubsub.publisher`,
|
|
382
|
+
createSubscription: `gcloud --project ${projectId} pubsub subscriptions create ${subscriptionName} --topic ${topicName} --push-endpoint "${pushEndpoint}"`,
|
|
383
|
+
}
|
|
384
|
+
: null;
|
|
385
|
+
return {
|
|
386
|
+
client: normalizedClient,
|
|
387
|
+
projectId: projectId || null,
|
|
388
|
+
topicPath: topicPath || null,
|
|
389
|
+
topicName,
|
|
390
|
+
subscriptionName,
|
|
391
|
+
pushEndpoint,
|
|
392
|
+
commands,
|
|
393
|
+
configured: Boolean(topicPath && push.token && projectId),
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const getConfig = ({ req }) => {
|
|
398
|
+
let state = readState();
|
|
399
|
+
const ensuredPush = ensurePushToken({ state });
|
|
400
|
+
state = ensuredPush.state;
|
|
401
|
+
saveState(state);
|
|
402
|
+
const baseUrl = getBaseUrl(req);
|
|
403
|
+
const clients = Array.from(
|
|
404
|
+
new Set(
|
|
405
|
+
listGoogleAccounts(state).map(
|
|
406
|
+
(account) => String(account.client || "default").trim() || "default",
|
|
407
|
+
),
|
|
408
|
+
),
|
|
409
|
+
);
|
|
410
|
+
const clientConfigs = clients.map((client) =>
|
|
411
|
+
buildClientConfig({ state, client, baseUrl }),
|
|
412
|
+
);
|
|
413
|
+
const serveStatuses = new Map(
|
|
414
|
+
serveManager
|
|
415
|
+
.listServeStatuses()
|
|
416
|
+
.map((status) => [String(status.accountId || ""), status]),
|
|
417
|
+
);
|
|
418
|
+
const accounts = listGoogleAccounts(state).map((account) => {
|
|
419
|
+
const watch = getAccountGmailWatch(account);
|
|
420
|
+
const serve = serveStatuses.get(String(account.id || "")) || null;
|
|
421
|
+
return {
|
|
422
|
+
accountId: account.id,
|
|
423
|
+
email: account.email,
|
|
424
|
+
client: account.client || "default",
|
|
425
|
+
enabled: watch.enabled,
|
|
426
|
+
port: watch.port || null,
|
|
427
|
+
expiration: watch.expiration || null,
|
|
428
|
+
lastPushAt: watch.lastPushAt || null,
|
|
429
|
+
pid: serve?.pid || watch.pid || null,
|
|
430
|
+
running: Boolean(serve?.running),
|
|
431
|
+
};
|
|
432
|
+
});
|
|
433
|
+
return {
|
|
434
|
+
ok: true,
|
|
435
|
+
pushToken: ensuredPush.token,
|
|
436
|
+
pushEndpoint: `${baseUrl}/gmail-pubsub?token=${encodeURIComponent(
|
|
437
|
+
ensuredPush.token,
|
|
438
|
+
)}`,
|
|
439
|
+
clients: clientConfigs,
|
|
440
|
+
accounts,
|
|
441
|
+
};
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const saveClientConfig = ({ req, body = {} }) => {
|
|
445
|
+
let state = readState();
|
|
446
|
+
const client = String(body.client || "default").trim() || "default";
|
|
447
|
+
const ensuredPush = ensurePushToken({
|
|
448
|
+
state,
|
|
449
|
+
forceRegenerate: Boolean(body.regeneratePushToken),
|
|
450
|
+
});
|
|
451
|
+
state = ensuredPush.state;
|
|
452
|
+
let topicPath = String(body.topicPath || "").trim();
|
|
453
|
+
if (!topicPath) {
|
|
454
|
+
const ensuredTopic = ensureTopicPathForClient({
|
|
455
|
+
state,
|
|
456
|
+
client,
|
|
457
|
+
readGoogleCredentials,
|
|
458
|
+
projectIdOverride: String(body.projectId || "").trim(),
|
|
459
|
+
});
|
|
460
|
+
state = ensuredTopic.state;
|
|
461
|
+
topicPath = ensuredTopic.topicPath;
|
|
462
|
+
} else {
|
|
463
|
+
const updatedPush = setGmailPushConfig({
|
|
464
|
+
state,
|
|
465
|
+
config: {
|
|
466
|
+
topics: {
|
|
467
|
+
[client]: topicPath,
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
state = updatedPush.state;
|
|
472
|
+
}
|
|
473
|
+
saveState(state);
|
|
474
|
+
const baseUrl = getBaseUrl(req);
|
|
475
|
+
return {
|
|
476
|
+
ok: true,
|
|
477
|
+
client: buildClientConfig({ state, client, baseUrl }),
|
|
478
|
+
topicPath,
|
|
479
|
+
pushToken: getGmailPushConfig(state).token,
|
|
480
|
+
};
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const startWatch = async ({ accountId, req }) => {
|
|
484
|
+
let state = readState();
|
|
485
|
+
const account = getGoogleAccountById(state, accountId);
|
|
486
|
+
if (!account) throw new Error("Google account not found");
|
|
487
|
+
if (!Array.isArray(account.services) || !account.services.includes("gmail:read")) {
|
|
488
|
+
throw new Error("Account is missing gmail:read permission");
|
|
489
|
+
}
|
|
490
|
+
const client = String(account.client || "default").trim() || "default";
|
|
491
|
+
const ensuredPush = ensurePushToken({ state });
|
|
492
|
+
state = ensuredPush.state;
|
|
493
|
+
const ensuredTopic = ensureTopicPathForClient({
|
|
494
|
+
state,
|
|
495
|
+
client,
|
|
496
|
+
readGoogleCredentials,
|
|
497
|
+
});
|
|
498
|
+
state = ensuredTopic.state;
|
|
499
|
+
const topicPath = ensuredTopic.topicPath;
|
|
500
|
+
|
|
501
|
+
const { webhookToken } = ensureHookWiring();
|
|
502
|
+
const watchStart = await runGogForAccount({
|
|
503
|
+
account,
|
|
504
|
+
command:
|
|
505
|
+
`gmail watch start --json --account ${quoteShellArg(account.email)} ` +
|
|
506
|
+
`--topic ${quoteShellArg(topicPath)} --label INBOX`,
|
|
507
|
+
});
|
|
508
|
+
if (!watchStart.ok) {
|
|
509
|
+
throw new Error(watchStart.stderr || "Failed to start Gmail watch");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const currentWatch = getAccountGmailWatch(account);
|
|
513
|
+
const selectedPort =
|
|
514
|
+
currentWatch.port ||
|
|
515
|
+
allocateServePort({
|
|
516
|
+
state,
|
|
517
|
+
basePort: constants.kGmailServeBasePort,
|
|
518
|
+
maxAccounts: constants.kMaxGoogleAccounts,
|
|
519
|
+
});
|
|
520
|
+
if (!selectedPort) {
|
|
521
|
+
throw new Error("No available Gmail watch serve ports");
|
|
522
|
+
}
|
|
523
|
+
const serveStatus = await serveManager.startServe({
|
|
524
|
+
account,
|
|
525
|
+
port: selectedPort,
|
|
526
|
+
webhookToken,
|
|
527
|
+
});
|
|
528
|
+
const expiration = parseExpirationFromOutput(watchStart.stdout);
|
|
529
|
+
const updated = setAccountGmailWatch({
|
|
530
|
+
state,
|
|
531
|
+
accountId,
|
|
532
|
+
watch: {
|
|
533
|
+
enabled: true,
|
|
534
|
+
port: selectedPort,
|
|
535
|
+
expiration,
|
|
536
|
+
pid: serveStatus.pid || null,
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
state = updated.state;
|
|
540
|
+
saveState(state);
|
|
541
|
+
return {
|
|
542
|
+
ok: true,
|
|
543
|
+
accountId,
|
|
544
|
+
client,
|
|
545
|
+
topicPath,
|
|
546
|
+
watch: getAccountGmailWatch(updated.account),
|
|
547
|
+
serve: serveStatus,
|
|
548
|
+
};
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const stopWatch = async ({ accountId }) => {
|
|
552
|
+
let state = readState();
|
|
553
|
+
const account = getGoogleAccountById(state, accountId);
|
|
554
|
+
if (!account) return { ok: true, accountId, skipped: true };
|
|
555
|
+
|
|
556
|
+
await serveManager.stopServe({ accountId });
|
|
557
|
+
const watchStop = await runGogForAccount({
|
|
558
|
+
account,
|
|
559
|
+
command: `gmail watch stop --account ${quoteShellArg(account.email)} --force`,
|
|
560
|
+
});
|
|
561
|
+
if (!watchStop.ok) {
|
|
562
|
+
console.log(
|
|
563
|
+
`[alphaclaw] Gmail watch stop warning (${account.email}): ${watchStop.stderr || "unknown"}`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
const updated = setAccountGmailWatch({
|
|
567
|
+
state,
|
|
568
|
+
accountId,
|
|
569
|
+
watch: {
|
|
570
|
+
enabled: false,
|
|
571
|
+
pid: null,
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
state = updated.state;
|
|
575
|
+
saveState(state);
|
|
576
|
+
return { ok: true, accountId, watch: getAccountGmailWatch(updated.account) };
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const renewWatch = async ({ accountId = "", force = false }) => {
|
|
580
|
+
let state = readState();
|
|
581
|
+
const now = Date.now();
|
|
582
|
+
const allTargets = accountId
|
|
583
|
+
? [getGoogleAccountById(state, accountId)].filter(Boolean)
|
|
584
|
+
: listWatchEnabledAccounts(state);
|
|
585
|
+
const results = [];
|
|
586
|
+
for (const account of allTargets) {
|
|
587
|
+
const watch = getAccountGmailWatch(account);
|
|
588
|
+
const shouldRenew =
|
|
589
|
+
force ||
|
|
590
|
+
!watch.expiration ||
|
|
591
|
+
watch.expiration - now <= constants.kGmailWatchRenewalThresholdMs;
|
|
592
|
+
if (!shouldRenew) {
|
|
593
|
+
results.push({
|
|
594
|
+
accountId: account.id,
|
|
595
|
+
skipped: true,
|
|
596
|
+
reason: "not_due",
|
|
597
|
+
});
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
// eslint-disable-next-line no-await-in-loop
|
|
602
|
+
const renewed = await startWatch({ accountId: account.id, req: null });
|
|
603
|
+
results.push({
|
|
604
|
+
accountId: account.id,
|
|
605
|
+
renewed: true,
|
|
606
|
+
expiration: renewed.watch.expiration || null,
|
|
607
|
+
});
|
|
608
|
+
} catch (err) {
|
|
609
|
+
results.push({
|
|
610
|
+
accountId: account.id,
|
|
611
|
+
renewed: false,
|
|
612
|
+
error: err.message || "renew_failed",
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return { ok: true, results };
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
let renewalTimer = null;
|
|
620
|
+
const start = () => {
|
|
621
|
+
const run = async () => {
|
|
622
|
+
try {
|
|
623
|
+
await renewWatch({ force: false });
|
|
624
|
+
} catch (err) {
|
|
625
|
+
console.error("[alphaclaw] Gmail watch renewal error:", err);
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
if (renewalTimer) clearInterval(renewalTimer);
|
|
629
|
+
renewalTimer = setInterval(run, constants.kGmailWatchRenewalIntervalMs);
|
|
630
|
+
renewalTimer.unref?.();
|
|
631
|
+
|
|
632
|
+
setTimeout(async () => {
|
|
633
|
+
try {
|
|
634
|
+
let state = readState();
|
|
635
|
+
const hookToken = String(
|
|
636
|
+
process.env.OPENCLAW_HOOKS_TOKEN || process.env.WEBHOOK_TOKEN || "",
|
|
637
|
+
).trim();
|
|
638
|
+
const enabled = listWatchEnabledAccounts(state);
|
|
639
|
+
for (const account of enabled) {
|
|
640
|
+
const watch = getAccountGmailWatch(account);
|
|
641
|
+
if (!watch.enabled || !watch.port || !hookToken) continue;
|
|
642
|
+
try {
|
|
643
|
+
// eslint-disable-next-line no-await-in-loop
|
|
644
|
+
const serveStatus = await serveManager.startServe({
|
|
645
|
+
account,
|
|
646
|
+
port: watch.port,
|
|
647
|
+
webhookToken: hookToken,
|
|
648
|
+
});
|
|
649
|
+
const updated = setAccountGmailWatch({
|
|
650
|
+
state,
|
|
651
|
+
accountId: account.id,
|
|
652
|
+
watch: { pid: serveStatus.pid || null },
|
|
653
|
+
});
|
|
654
|
+
state = updated.state;
|
|
655
|
+
} catch (err) {
|
|
656
|
+
console.error(
|
|
657
|
+
`[alphaclaw] Failed to restore Gmail serve for ${account.email}: ${err.message || "unknown"}`,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
saveState(state);
|
|
662
|
+
await run();
|
|
663
|
+
} catch (err) {
|
|
664
|
+
console.error("[alphaclaw] Failed to bootstrap Gmail watch services:", err);
|
|
665
|
+
}
|
|
666
|
+
}, 0);
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const stop = async () => {
|
|
670
|
+
if (renewalTimer) {
|
|
671
|
+
clearInterval(renewalTimer);
|
|
672
|
+
renewalTimer = null;
|
|
673
|
+
}
|
|
674
|
+
await serveManager.stopAll();
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
const getTargetByEmail = (email = "") => {
|
|
678
|
+
const state = readState();
|
|
679
|
+
const account = getGoogleAccountByEmail(state, email);
|
|
680
|
+
if (!account) return null;
|
|
681
|
+
const watch = getAccountGmailWatch(account);
|
|
682
|
+
if (!watch.enabled || !watch.port) return null;
|
|
683
|
+
return {
|
|
684
|
+
accountId: account.id,
|
|
685
|
+
port: watch.port,
|
|
686
|
+
email: account.email,
|
|
687
|
+
client: account.client || "default",
|
|
688
|
+
};
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
const markPushReceived = ({ accountId, at }) => {
|
|
692
|
+
const state = readState();
|
|
693
|
+
const updated = setAccountGmailWatch({
|
|
694
|
+
state,
|
|
695
|
+
accountId,
|
|
696
|
+
watch: {
|
|
697
|
+
lastPushAt: Number.parseInt(String(at || Date.now()), 10),
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
saveState(updated.state);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
serviceRef = {
|
|
704
|
+
start,
|
|
705
|
+
stop,
|
|
706
|
+
getConfig,
|
|
707
|
+
saveClientConfig,
|
|
708
|
+
startWatch,
|
|
709
|
+
stopWatch,
|
|
710
|
+
renewWatch,
|
|
711
|
+
getTargetByEmail,
|
|
712
|
+
markPushReceived,
|
|
713
|
+
resolvePushToken: () => getGmailPushConfig(readState()).token,
|
|
714
|
+
getServeStatus: (accountId) => serveManager.getServeStatus(accountId),
|
|
715
|
+
ensureHookWiring,
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
return serviceRef;
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
module.exports = {
|
|
722
|
+
createGmailWatchService,
|
|
723
|
+
createTopicNameForClient,
|
|
724
|
+
createSubscriptionNameForClient,
|
|
725
|
+
};
|