@chrysb/alphaclaw 0.1.25 → 0.2.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 +391 -54
- package/lib/public/js/app.js +38 -19
- package/lib/public/js/components/channels.js +14 -3
- package/lib/public/js/components/telegram-workspace.js +1377 -0
- package/lib/server/constants.js +1 -0
- package/lib/server/helpers.js +32 -0
- package/lib/server/onboarding/github.js +1 -2
- package/lib/server/onboarding/index.js +9 -7
- package/lib/server/onboarding/openclaw.js +4 -11
- package/lib/server/onboarding/workspace.js +26 -3
- package/lib/server/routes/auth.js +24 -6
- package/lib/server/routes/proxy.js +4 -4
- package/lib/server/routes/telegram.js +407 -0
- package/lib/server/telegram-api.js +65 -0
- package/lib/server/telegram-workspace.js +82 -0
- package/lib/server/topic-registry.js +152 -0
- package/lib/server.js +7 -1
- package/lib/setup/core-prompts/TOOLS.md +1 -1
- package/lib/setup/env.template +3 -0
- package/lib/setup/hourly-git-sync.sh +9 -10
- package/package.json +1 -1
package/lib/server/constants.js
CHANGED
package/lib/server/helpers.js
CHANGED
|
@@ -63,6 +63,8 @@ const normalizeIp = (ip) => String(ip || "").replace(/^::ffff:/, "");
|
|
|
63
63
|
|
|
64
64
|
const isTruthyEnvFlag = (value) =>
|
|
65
65
|
["1", "true", "yes", "on"].includes(String(value || "").trim().toLowerCase());
|
|
66
|
+
const isDebugEnabled = () =>
|
|
67
|
+
isTruthyEnvFlag(process.env.ALPHACLAW_DEBUG) || isTruthyEnvFlag(process.env.DEBUG);
|
|
66
68
|
|
|
67
69
|
const getClientKey = (req) =>
|
|
68
70
|
normalizeIp(
|
|
@@ -172,6 +174,33 @@ const readGoogleCredentials = () => {
|
|
|
172
174
|
}
|
|
173
175
|
};
|
|
174
176
|
|
|
177
|
+
const kSecretKeyMatchers = [
|
|
178
|
+
/(?:^|_)TOKEN(?:$|_)/i,
|
|
179
|
+
/(?:^|_)API_KEY(?:$|_)/i,
|
|
180
|
+
/(?:^|_)PASSWORD(?:$|_)/i,
|
|
181
|
+
/(?:^|_)SECRET(?:$|_)/i,
|
|
182
|
+
/(?:^|_)PRIVATE_KEY(?:$|_)/i,
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const isSensitiveKey = (key) =>
|
|
186
|
+
kSecretKeyMatchers.some((matcher) => matcher.test(String(key || "")));
|
|
187
|
+
|
|
188
|
+
const buildSecretReplacements = (...sources) => {
|
|
189
|
+
const replacements = [];
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
for (const source of sources) {
|
|
192
|
+
for (const [rawKey, rawValue] of Object.entries(source || {})) {
|
|
193
|
+
const key = String(rawKey || "").trim();
|
|
194
|
+
const value = String(rawValue || "");
|
|
195
|
+
if (!key || !value || !isSensitiveKey(key)) continue;
|
|
196
|
+
if (seen.has(value)) continue;
|
|
197
|
+
seen.add(value);
|
|
198
|
+
replacements.push([value, `\${${key}}`]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return replacements.sort((a, b) => b[0].length - a[0].length);
|
|
202
|
+
};
|
|
203
|
+
|
|
175
204
|
module.exports = {
|
|
176
205
|
normalizeOpenclawVersion,
|
|
177
206
|
compareVersionParts,
|
|
@@ -180,6 +209,7 @@ module.exports = {
|
|
|
180
209
|
getCodexAccountId,
|
|
181
210
|
normalizeIp,
|
|
182
211
|
isTruthyEnvFlag,
|
|
212
|
+
isDebugEnabled,
|
|
183
213
|
getClientKey,
|
|
184
214
|
resolveGithubRepoUrl,
|
|
185
215
|
createPkcePair,
|
|
@@ -189,4 +219,6 @@ module.exports = {
|
|
|
189
219
|
getBaseUrl,
|
|
190
220
|
getApiEnableUrl,
|
|
191
221
|
readGoogleCredentials,
|
|
222
|
+
isSensitiveKey,
|
|
223
|
+
buildSecretReplacements,
|
|
192
224
|
};
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
const ensureGithubRepoAccessible = async ({ repoUrl, repoName,
|
|
2
|
-
void remoteUrl;
|
|
1
|
+
const ensureGithubRepoAccessible = async ({ repoUrl, repoName, githubToken }) => {
|
|
3
2
|
const ghHeaders = {
|
|
4
3
|
Authorization: `token ${githubToken}`,
|
|
5
4
|
"User-Agent": "openclaw-railway",
|
|
@@ -5,7 +5,6 @@ const { ensureGithubRepoAccessible } = require("./github");
|
|
|
5
5
|
const { buildOnboardArgs, writeSanitizedOpenclawConfig } = require("./openclaw");
|
|
6
6
|
const { installControlUiSkill, syncBootstrapPromptFiles } = require("./workspace");
|
|
7
7
|
const { installHourlyGitSyncScript, installHourlyGitSyncCron } = require("./cron");
|
|
8
|
-
const { isTruthyEnvFlag } = require("../helpers");
|
|
9
8
|
|
|
10
9
|
const createOnboardingService = ({
|
|
11
10
|
fs,
|
|
@@ -43,12 +42,11 @@ const createOnboardingService = ({
|
|
|
43
42
|
writeEnvFile(varsToSave);
|
|
44
43
|
reloadEnv();
|
|
45
44
|
|
|
46
|
-
const remoteUrl = `https
|
|
45
|
+
const remoteUrl = `https://github.com/${repoUrl}.git`;
|
|
47
46
|
const [, repoName] = repoUrl.split("/");
|
|
48
47
|
const repoCheck = await ensureGithubRepoAccessible({
|
|
49
48
|
repoUrl,
|
|
50
49
|
repoName,
|
|
51
|
-
remoteUrl,
|
|
52
50
|
githubToken,
|
|
53
51
|
});
|
|
54
52
|
if (!repoCheck.ok) {
|
|
@@ -103,11 +101,15 @@ const createOnboardingService = ({
|
|
|
103
101
|
|
|
104
102
|
installControlUiSkill({ fs, openclawDir: OPENCLAW_DIR, baseUrl: getBaseUrl(req) });
|
|
105
103
|
|
|
106
|
-
const shouldForcePush = isTruthyEnvFlag(process.env.OPENCLAW_DEBUG_FORCE_PUSH);
|
|
107
|
-
const pushArgs = shouldForcePush ? "-u --force" : "-u";
|
|
108
104
|
await shellCmd(
|
|
109
|
-
`
|
|
110
|
-
{
|
|
105
|
+
`alphaclaw git-sync -m "initial setup"`,
|
|
106
|
+
{
|
|
107
|
+
timeout: 30000,
|
|
108
|
+
env: {
|
|
109
|
+
...process.env,
|
|
110
|
+
GITHUB_TOKEN: githubToken,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
111
113
|
).catch((e) => console.error("[onboard] Git push error:", e.message));
|
|
112
114
|
console.log("[onboard] Initial state committed and pushed");
|
|
113
115
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { buildSecretReplacements } = require("../helpers");
|
|
2
|
+
|
|
1
3
|
const buildOnboardArgs = ({ varMap, selectedProvider, hasCodexOauth, workspaceDir }) => {
|
|
2
4
|
const onboardArgs = [
|
|
3
5
|
"--non-interactive",
|
|
@@ -149,18 +151,9 @@ const writeSanitizedOpenclawConfig = ({ fs, openclawDir, varMap }) => {
|
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
let content = JSON.stringify(cfg, null, 2);
|
|
152
|
-
const replacements =
|
|
153
|
-
[process.env.OPENCLAW_GATEWAY_TOKEN, "${OPENCLAW_GATEWAY_TOKEN}"],
|
|
154
|
-
[varMap.ANTHROPIC_API_KEY, "${ANTHROPIC_API_KEY}"],
|
|
155
|
-
[varMap.ANTHROPIC_TOKEN, "${ANTHROPIC_TOKEN}"],
|
|
156
|
-
[varMap.TELEGRAM_BOT_TOKEN, "${TELEGRAM_BOT_TOKEN}"],
|
|
157
|
-
[varMap.DISCORD_BOT_TOKEN, "${DISCORD_BOT_TOKEN}"],
|
|
158
|
-
[varMap.OPENAI_API_KEY, "${OPENAI_API_KEY}"],
|
|
159
|
-
[varMap.GEMINI_API_KEY, "${GEMINI_API_KEY}"],
|
|
160
|
-
[varMap.BRAVE_API_KEY, "${BRAVE_API_KEY}"],
|
|
161
|
-
];
|
|
154
|
+
const replacements = buildSecretReplacements(varMap, process.env);
|
|
162
155
|
for (const [secret, envRef] of replacements) {
|
|
163
|
-
if (secret
|
|
156
|
+
if (secret) {
|
|
164
157
|
content = content.split(secret).join(envRef);
|
|
165
158
|
}
|
|
166
159
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const path = require("path");
|
|
2
|
-
const { kSetupDir } = require("../constants");
|
|
2
|
+
const { kSetupDir, OPENCLAW_DIR } = require("../constants");
|
|
3
|
+
const { renderTopicRegistryMarkdown } = require("../topic-registry");
|
|
3
4
|
|
|
4
5
|
const resolveSetupUiUrl = (baseUrl) => {
|
|
5
6
|
const normalizedBaseUrl = String(baseUrl || "").trim().replace(/\/+$/, "");
|
|
@@ -19,16 +20,38 @@ const resolveSetupUiUrl = (baseUrl) => {
|
|
|
19
20
|
return "http://localhost:3000";
|
|
20
21
|
};
|
|
21
22
|
|
|
23
|
+
// Single assembly point for TOOLS.md: template + topic registry.
|
|
24
|
+
// Idempotent — always rebuilds from source so deploys never clobber topic data.
|
|
25
|
+
const isTelegramWorkspaceEnabled = (fs) => {
|
|
26
|
+
try {
|
|
27
|
+
const configPath = `${OPENCLAW_DIR}/openclaw.json`;
|
|
28
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
29
|
+
return Object.keys(cfg.channels?.telegram?.groups || {}).length > 0;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
22
35
|
const syncBootstrapPromptFiles = ({ fs, workspaceDir, baseUrl }) => {
|
|
23
36
|
try {
|
|
37
|
+
const setupUiUrl = resolveSetupUiUrl(baseUrl);
|
|
24
38
|
const bootstrapDir = `${workspaceDir}/hooks/bootstrap`;
|
|
25
39
|
fs.mkdirSync(bootstrapDir, { recursive: true });
|
|
26
40
|
fs.copyFileSync(path.join(kSetupDir, "core-prompts", "AGENTS.md"), `${bootstrapDir}/AGENTS.md`);
|
|
41
|
+
|
|
27
42
|
const toolsTemplate = fs.readFileSync(path.join(kSetupDir, "core-prompts", "TOOLS.md"), "utf8");
|
|
28
|
-
|
|
43
|
+
let toolsContent = toolsTemplate.replace(
|
|
29
44
|
/\{\{SETUP_UI_URL\}\}/g,
|
|
30
|
-
|
|
45
|
+
setupUiUrl,
|
|
31
46
|
);
|
|
47
|
+
|
|
48
|
+
const topicSection = renderTopicRegistryMarkdown({
|
|
49
|
+
includeSyncGuidance: isTelegramWorkspaceEnabled(fs),
|
|
50
|
+
});
|
|
51
|
+
if (topicSection) {
|
|
52
|
+
toolsContent += topicSection;
|
|
53
|
+
}
|
|
54
|
+
|
|
32
55
|
fs.writeFileSync(`${bootstrapDir}/TOOLS.md`, toolsContent);
|
|
33
56
|
console.log("[onboard] Bootstrap prompt files synced");
|
|
34
57
|
} catch (e) {
|
|
@@ -2,7 +2,8 @@ const crypto = require("crypto");
|
|
|
2
2
|
const { kLoginCleanupIntervalMs } = require("../constants");
|
|
3
3
|
|
|
4
4
|
const registerAuthRoutes = ({ app, loginThrottle }) => {
|
|
5
|
-
const SETUP_PASSWORD = process.env.SETUP_PASSWORD || "";
|
|
5
|
+
const SETUP_PASSWORD = String(process.env.SETUP_PASSWORD || "").trim();
|
|
6
|
+
const kAuthMisconfigured = !SETUP_PASSWORD;
|
|
6
7
|
const kSessionTtlMs = 7 * 24 * 60 * 60 * 1000;
|
|
7
8
|
|
|
8
9
|
const signSessionPayload = (payload) =>
|
|
@@ -50,7 +51,13 @@ const registerAuthRoutes = ({ app, loginThrottle }) => {
|
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
app.post("/api/auth/login", (req, res) => {
|
|
53
|
-
if (
|
|
54
|
+
if (kAuthMisconfigured) {
|
|
55
|
+
return res.status(503).json({
|
|
56
|
+
ok: false,
|
|
57
|
+
error:
|
|
58
|
+
"Server misconfigured: SETUP_PASSWORD is missing. Set it in your deployment environment variables and restart.",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
54
61
|
const now = Date.now();
|
|
55
62
|
const clientKey = loginThrottle.getClientKey(req);
|
|
56
63
|
const state = loginThrottle.getOrCreateLoginAttemptState(clientKey, now);
|
|
@@ -92,18 +99,29 @@ const registerAuthRoutes = ({ app, loginThrottle }) => {
|
|
|
92
99
|
}, kLoginCleanupIntervalMs).unref();
|
|
93
100
|
|
|
94
101
|
const isAuthorizedRequest = (req) => {
|
|
95
|
-
if (
|
|
102
|
+
if (kAuthMisconfigured) return false;
|
|
96
103
|
const requestPath = req.path || "";
|
|
97
104
|
if (requestPath.startsWith("/auth/google/callback")) return true;
|
|
98
105
|
if (requestPath.startsWith("/auth/codex/callback")) return true;
|
|
99
106
|
const cookies = cookieParser(req);
|
|
100
|
-
const
|
|
101
|
-
const token = cookies.setup_token || query.token;
|
|
107
|
+
const token = cookies.setup_token;
|
|
102
108
|
return verifySessionToken(token);
|
|
103
109
|
};
|
|
104
110
|
|
|
105
111
|
const requireAuth = (req, res, next) => {
|
|
106
|
-
if (
|
|
112
|
+
if (kAuthMisconfigured) {
|
|
113
|
+
if (req.path.startsWith("/api/")) {
|
|
114
|
+
return res.status(503).json({
|
|
115
|
+
error:
|
|
116
|
+
"Server misconfigured: SETUP_PASSWORD is missing. Set it in your deployment environment variables and restart.",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return res
|
|
120
|
+
.status(503)
|
|
121
|
+
.send(
|
|
122
|
+
"Setup auth is not configured. Set SETUP_PASSWORD in your deployment environment and restart.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
107
125
|
if (req.path.startsWith("/auth/google/callback")) return next();
|
|
108
126
|
if (req.path.startsWith("/auth/codex/callback")) return next();
|
|
109
127
|
if (isAuthorizedRequest(req)) return next();
|
|
@@ -3,13 +3,13 @@ const registerProxyRoutes = ({ app, proxy, SETUP_API_PREFIXES, requireAuth }) =>
|
|
|
3
3
|
req.url = "/";
|
|
4
4
|
proxy.web(req, res);
|
|
5
5
|
});
|
|
6
|
-
app.all("/openclaw/*", requireAuth, (req, res) => {
|
|
6
|
+
app.all("/openclaw/*path", requireAuth, (req, res) => {
|
|
7
7
|
req.url = req.url.replace(/^\/openclaw/, "");
|
|
8
8
|
proxy.web(req, res);
|
|
9
9
|
});
|
|
10
|
-
app.all("/assets/*", requireAuth, (req, res) => proxy.web(req, res));
|
|
10
|
+
app.all("/assets/*path", requireAuth, (req, res) => proxy.web(req, res));
|
|
11
11
|
|
|
12
|
-
app.all("/webhook/*", (req, res) => {
|
|
12
|
+
app.all("/webhook/*path", (req, res) => {
|
|
13
13
|
if (!req.headers.authorization && req.query.token) {
|
|
14
14
|
req.headers.authorization = `Bearer ${req.query.token}`;
|
|
15
15
|
delete req.query.token;
|
|
@@ -20,7 +20,7 @@ const registerProxyRoutes = ({ app, proxy, SETUP_API_PREFIXES, requireAuth }) =>
|
|
|
20
20
|
proxy.web(req, res);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
app.all("/api/*", (req, res) => {
|
|
23
|
+
app.all("/api/*path", (req, res) => {
|
|
24
24
|
if (SETUP_API_PREFIXES.some((p) => req.path.startsWith(p))) return;
|
|
25
25
|
proxy.web(req, res);
|
|
26
26
|
});
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const { OPENCLAW_DIR } = require("../constants");
|
|
3
|
+
const { isDebugEnabled } = require("../helpers");
|
|
4
|
+
const topicRegistry = require("../topic-registry");
|
|
5
|
+
const { syncConfigForTelegram } = require("../telegram-workspace");
|
|
6
|
+
|
|
7
|
+
const getRequestBody = (req) => (req.body && typeof req.body === "object" ? req.body : {});
|
|
8
|
+
const getRequestQuery = (req) => (req.query && typeof req.query === "object" ? req.query : {});
|
|
9
|
+
const parseJsonString = (value) => {
|
|
10
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(value);
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const getRequestPayload = (req) => {
|
|
18
|
+
const body = getRequestBody(req);
|
|
19
|
+
const query = getRequestQuery(req);
|
|
20
|
+
const payloadFromQuery = parseJsonString(query.payload);
|
|
21
|
+
if (payloadFromQuery && typeof payloadFromQuery === "object" && !Array.isArray(payloadFromQuery)) {
|
|
22
|
+
return { ...payloadFromQuery, ...body };
|
|
23
|
+
}
|
|
24
|
+
return body;
|
|
25
|
+
};
|
|
26
|
+
const parseBooleanValue = (value, fallbackValue = false) => {
|
|
27
|
+
if (typeof value === "boolean") return value;
|
|
28
|
+
if (typeof value === "number") return value !== 0;
|
|
29
|
+
if (typeof value === "string") {
|
|
30
|
+
const normalized = value.trim().toLowerCase();
|
|
31
|
+
if (["true", "1", "yes", "on"].includes(normalized)) return true;
|
|
32
|
+
if (["false", "0", "no", "off", ""].includes(normalized)) return false;
|
|
33
|
+
}
|
|
34
|
+
return fallbackValue;
|
|
35
|
+
};
|
|
36
|
+
const resolveGroupId = (req) => {
|
|
37
|
+
const body = getRequestPayload(req);
|
|
38
|
+
const query = getRequestQuery(req);
|
|
39
|
+
const rawGroupId = body.groupId ?? body.chatId ?? query.groupId ?? query.chatId;
|
|
40
|
+
return rawGroupId == null ? "" : String(rawGroupId).trim();
|
|
41
|
+
};
|
|
42
|
+
const resolveAllowUserId = async ({ telegramApi, groupId, preferredUserId }) => {
|
|
43
|
+
const normalizedPreferred = String(preferredUserId || "").trim();
|
|
44
|
+
if (normalizedPreferred) return normalizedPreferred;
|
|
45
|
+
const admins = await telegramApi.getChatAdministrators(groupId);
|
|
46
|
+
const humanAdmins = admins.filter((entry) => !entry?.user?.is_bot);
|
|
47
|
+
if (humanAdmins.length === 0) return "";
|
|
48
|
+
const creator = humanAdmins.find((entry) => entry.status === "creator");
|
|
49
|
+
const targetAdmin = creator || humanAdmins[0];
|
|
50
|
+
return String(targetAdmin?.user?.id || "").trim();
|
|
51
|
+
};
|
|
52
|
+
const isMissingTopicError = (errorMessage) => {
|
|
53
|
+
const message = String(errorMessage || "").toLowerCase();
|
|
54
|
+
return [
|
|
55
|
+
"topic_id_invalid",
|
|
56
|
+
"message_thread_id_invalid",
|
|
57
|
+
"message_thread_not_found",
|
|
58
|
+
"topic_not_found",
|
|
59
|
+
"message thread not found",
|
|
60
|
+
"topic not found",
|
|
61
|
+
"invalid thread id",
|
|
62
|
+
"invalid topic id",
|
|
63
|
+
].some((token) => message.includes(token));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => {
|
|
67
|
+
// Verify bot token
|
|
68
|
+
app.get("/api/telegram/bot", async (req, res) => {
|
|
69
|
+
try {
|
|
70
|
+
const me = await telegramApi.getMe();
|
|
71
|
+
res.json({ ok: true, bot: me });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
res.json({ ok: false, error: e.message });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Verify group: checks bot membership, admin rights, topics enabled
|
|
78
|
+
app.post("/api/telegram/groups/verify", async (req, res) => {
|
|
79
|
+
const groupId = resolveGroupId(req);
|
|
80
|
+
if (!groupId) return res.status(400).json({ ok: false, error: "groupId is required" });
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const chat = await telegramApi.getChat(groupId);
|
|
84
|
+
const me = await telegramApi.getMe();
|
|
85
|
+
const member = await telegramApi.getChatMember(groupId, me.id);
|
|
86
|
+
const suggestedUserId = await resolveAllowUserId({
|
|
87
|
+
telegramApi,
|
|
88
|
+
groupId,
|
|
89
|
+
preferredUserId: "",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const isAdmin = member.status === "administrator" || member.status === "creator";
|
|
93
|
+
const isForum = !!chat.is_forum;
|
|
94
|
+
|
|
95
|
+
res.json({
|
|
96
|
+
ok: true,
|
|
97
|
+
chat: {
|
|
98
|
+
id: chat.id,
|
|
99
|
+
title: chat.title,
|
|
100
|
+
type: chat.type,
|
|
101
|
+
isForum,
|
|
102
|
+
},
|
|
103
|
+
bot: {
|
|
104
|
+
status: member.status,
|
|
105
|
+
isAdmin,
|
|
106
|
+
canManageTopics: isAdmin && (member.can_manage_topics !== false),
|
|
107
|
+
},
|
|
108
|
+
suggestedUserId: suggestedUserId || null,
|
|
109
|
+
});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
res.json({ ok: false, error: e.message });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// List topics from registry
|
|
116
|
+
app.get("/api/telegram/groups/:groupId/topics", (req, res) => {
|
|
117
|
+
const group = topicRegistry.getGroup(req.params.groupId);
|
|
118
|
+
res.json({ ok: true, topics: group?.topics || {} });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Create a topic via Telegram API + add to registry
|
|
122
|
+
app.post("/api/telegram/groups/:groupId/topics", async (req, res) => {
|
|
123
|
+
const { groupId } = req.params;
|
|
124
|
+
const payload = getRequestPayload(req);
|
|
125
|
+
const query = getRequestQuery(req);
|
|
126
|
+
const name = String(payload.name ?? query.name ?? "").trim();
|
|
127
|
+
const rawIconColor = payload.iconColor ?? query.iconColor;
|
|
128
|
+
const systemInstructions = String(
|
|
129
|
+
payload.systemInstructions ?? payload.systemPrompt ?? query.systemInstructions ?? query.systemPrompt ?? "",
|
|
130
|
+
).trim();
|
|
131
|
+
const iconColorValue = rawIconColor == null ? null : Number.parseInt(String(rawIconColor), 10);
|
|
132
|
+
const iconColor = Number.isFinite(iconColorValue) ? iconColorValue : undefined;
|
|
133
|
+
if (!name) return res.status(400).json({ ok: false, error: "name is required" });
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const result = await telegramApi.createForumTopic(groupId, name, {
|
|
137
|
+
iconColor,
|
|
138
|
+
});
|
|
139
|
+
const threadId = result.message_thread_id;
|
|
140
|
+
topicRegistry.addTopic(groupId, threadId, {
|
|
141
|
+
name: result.name,
|
|
142
|
+
iconColor: result.icon_color,
|
|
143
|
+
...(systemInstructions ? { systemInstructions } : {}),
|
|
144
|
+
});
|
|
145
|
+
syncConfigForTelegram({
|
|
146
|
+
fs,
|
|
147
|
+
openclawDir: OPENCLAW_DIR,
|
|
148
|
+
topicRegistry,
|
|
149
|
+
groupId,
|
|
150
|
+
requireMention: false,
|
|
151
|
+
resolvedUserId: "",
|
|
152
|
+
});
|
|
153
|
+
syncPromptFiles();
|
|
154
|
+
res.json({ ok: true, topic: { threadId, name: result.name, iconColor: result.icon_color } });
|
|
155
|
+
} catch (e) {
|
|
156
|
+
res.json({ ok: false, error: e.message });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Bulk-create topics
|
|
161
|
+
app.post("/api/telegram/groups/:groupId/topics/bulk", async (req, res) => {
|
|
162
|
+
const { groupId } = req.params;
|
|
163
|
+
const payload = getRequestPayload(req);
|
|
164
|
+
const query = getRequestQuery(req);
|
|
165
|
+
const queryTopics = parseJsonString(query.topics);
|
|
166
|
+
const topics = Array.isArray(payload.topics)
|
|
167
|
+
? payload.topics
|
|
168
|
+
: Array.isArray(queryTopics)
|
|
169
|
+
? queryTopics
|
|
170
|
+
: [];
|
|
171
|
+
if (!Array.isArray(topics) || topics.length === 0) {
|
|
172
|
+
return res.status(400).json({ ok: false, error: "topics array is required" });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const results = [];
|
|
176
|
+
for (const t of topics) {
|
|
177
|
+
if (!t.name) {
|
|
178
|
+
results.push({ name: t.name, ok: false, error: "name is required" });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const result = await telegramApi.createForumTopic(groupId, t.name, {
|
|
183
|
+
iconColor: t.iconColor || undefined,
|
|
184
|
+
});
|
|
185
|
+
const threadId = result.message_thread_id;
|
|
186
|
+
const systemInstructions = String(t.systemInstructions ?? t.systemPrompt ?? "").trim();
|
|
187
|
+
topicRegistry.addTopic(groupId, threadId, {
|
|
188
|
+
name: result.name,
|
|
189
|
+
iconColor: result.icon_color,
|
|
190
|
+
...(systemInstructions ? { systemInstructions } : {}),
|
|
191
|
+
});
|
|
192
|
+
results.push({ name: result.name, threadId, ok: true });
|
|
193
|
+
} catch (e) {
|
|
194
|
+
results.push({ name: t.name, ok: false, error: e.message });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
syncConfigForTelegram({
|
|
198
|
+
fs,
|
|
199
|
+
openclawDir: OPENCLAW_DIR,
|
|
200
|
+
topicRegistry,
|
|
201
|
+
groupId,
|
|
202
|
+
requireMention: false,
|
|
203
|
+
resolvedUserId: "",
|
|
204
|
+
});
|
|
205
|
+
syncPromptFiles();
|
|
206
|
+
res.json({ ok: true, results });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Delete a topic
|
|
210
|
+
app.delete("/api/telegram/groups/:groupId/topics/:topicId", async (req, res) => {
|
|
211
|
+
const { groupId, topicId } = req.params;
|
|
212
|
+
try {
|
|
213
|
+
await telegramApi.deleteForumTopic(groupId, parseInt(topicId, 10));
|
|
214
|
+
topicRegistry.removeTopic(groupId, topicId);
|
|
215
|
+
syncConfigForTelegram({
|
|
216
|
+
fs,
|
|
217
|
+
openclawDir: OPENCLAW_DIR,
|
|
218
|
+
topicRegistry,
|
|
219
|
+
groupId,
|
|
220
|
+
requireMention: false,
|
|
221
|
+
resolvedUserId: "",
|
|
222
|
+
});
|
|
223
|
+
syncPromptFiles();
|
|
224
|
+
res.json({ ok: true });
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (!isMissingTopicError(e?.message)) {
|
|
227
|
+
return res.json({ ok: false, error: e.message });
|
|
228
|
+
}
|
|
229
|
+
topicRegistry.removeTopic(groupId, topicId);
|
|
230
|
+
syncConfigForTelegram({
|
|
231
|
+
fs,
|
|
232
|
+
openclawDir: OPENCLAW_DIR,
|
|
233
|
+
topicRegistry,
|
|
234
|
+
groupId,
|
|
235
|
+
requireMention: false,
|
|
236
|
+
resolvedUserId: "",
|
|
237
|
+
});
|
|
238
|
+
syncPromptFiles();
|
|
239
|
+
return res.json({
|
|
240
|
+
ok: true,
|
|
241
|
+
removedFromRegistryOnly: true,
|
|
242
|
+
warning: "Topic no longer exists in Telegram; removed stale registry entry.",
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Rename a topic
|
|
248
|
+
app.put("/api/telegram/groups/:groupId/topics/:topicId", async (req, res) => {
|
|
249
|
+
const { groupId, topicId } = req.params;
|
|
250
|
+
const payload = getRequestPayload(req);
|
|
251
|
+
const query = getRequestQuery(req);
|
|
252
|
+
const name = String(payload.name ?? query.name ?? "").trim();
|
|
253
|
+
const hasSystemInstructions = Object.prototype.hasOwnProperty.call(payload, "systemInstructions")
|
|
254
|
+
|| Object.prototype.hasOwnProperty.call(payload, "systemPrompt")
|
|
255
|
+
|| Object.prototype.hasOwnProperty.call(query, "systemInstructions")
|
|
256
|
+
|| Object.prototype.hasOwnProperty.call(query, "systemPrompt");
|
|
257
|
+
const systemInstructions = String(
|
|
258
|
+
payload.systemInstructions ?? payload.systemPrompt ?? query.systemInstructions ?? query.systemPrompt ?? "",
|
|
259
|
+
).trim();
|
|
260
|
+
if (!name) return res.status(400).json({ ok: false, error: "name is required" });
|
|
261
|
+
try {
|
|
262
|
+
const threadId = Number.parseInt(String(topicId), 10);
|
|
263
|
+
if (!Number.isFinite(threadId)) {
|
|
264
|
+
return res.status(400).json({ ok: false, error: "topicId must be numeric" });
|
|
265
|
+
}
|
|
266
|
+
const existingTopic = topicRegistry.getGroup(groupId)?.topics?.[String(threadId)] || {};
|
|
267
|
+
const existingName = String(existingTopic.name || "").trim();
|
|
268
|
+
const shouldRename = !existingName || existingName !== name;
|
|
269
|
+
if (shouldRename) {
|
|
270
|
+
try {
|
|
271
|
+
await telegramApi.editForumTopic(groupId, threadId, { name });
|
|
272
|
+
} catch (e) {
|
|
273
|
+
// Telegram returns TOPIC_NOT_MODIFIED when the name is unchanged.
|
|
274
|
+
if (!String(e.message || "").includes("TOPIC_NOT_MODIFIED")) {
|
|
275
|
+
throw e;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
topicRegistry.updateTopic(groupId, threadId, {
|
|
280
|
+
...existingTopic,
|
|
281
|
+
name,
|
|
282
|
+
...(hasSystemInstructions ? { systemInstructions } : {}),
|
|
283
|
+
});
|
|
284
|
+
syncConfigForTelegram({
|
|
285
|
+
fs,
|
|
286
|
+
openclawDir: OPENCLAW_DIR,
|
|
287
|
+
topicRegistry,
|
|
288
|
+
groupId,
|
|
289
|
+
requireMention: false,
|
|
290
|
+
resolvedUserId: "",
|
|
291
|
+
});
|
|
292
|
+
syncPromptFiles();
|
|
293
|
+
return res.json({
|
|
294
|
+
ok: true,
|
|
295
|
+
topic: { threadId, name, ...(hasSystemInstructions ? { systemInstructions } : {}) },
|
|
296
|
+
});
|
|
297
|
+
} catch (e) {
|
|
298
|
+
return res.json({ ok: false, error: e.message });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Configure openclaw.json for a group
|
|
303
|
+
app.post("/api/telegram/groups/:groupId/configure", async (req, res) => {
|
|
304
|
+
const { groupId } = req.params;
|
|
305
|
+
const payload = getRequestPayload(req);
|
|
306
|
+
const query = getRequestQuery(req);
|
|
307
|
+
const userId = payload.userId ?? query.userId ?? "";
|
|
308
|
+
const groupName = payload.groupName ?? query.groupName ?? "";
|
|
309
|
+
const requireMention = parseBooleanValue(payload.requireMention ?? query.requireMention, false);
|
|
310
|
+
try {
|
|
311
|
+
const resolvedUserId = await resolveAllowUserId({
|
|
312
|
+
telegramApi,
|
|
313
|
+
groupId,
|
|
314
|
+
preferredUserId: userId,
|
|
315
|
+
});
|
|
316
|
+
syncConfigForTelegram({
|
|
317
|
+
fs,
|
|
318
|
+
openclawDir: OPENCLAW_DIR,
|
|
319
|
+
topicRegistry,
|
|
320
|
+
groupId,
|
|
321
|
+
requireMention,
|
|
322
|
+
resolvedUserId,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// Save metadata in local topic registry only.
|
|
326
|
+
if (groupName) {
|
|
327
|
+
topicRegistry.setGroup(groupId, { name: groupName });
|
|
328
|
+
syncPromptFiles();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
res.json({ ok: true, userId: resolvedUserId || null });
|
|
332
|
+
} catch (e) {
|
|
333
|
+
res.json({ ok: false, error: e.message });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Get full topic registry
|
|
338
|
+
app.get("/api/telegram/topic-registry", (req, res) => {
|
|
339
|
+
res.json({ ok: true, registry: topicRegistry.readRegistry() });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Workspace bootstrap info (lets UI jump straight to management)
|
|
343
|
+
app.get("/api/telegram/workspace", async (req, res) => {
|
|
344
|
+
try {
|
|
345
|
+
const debugEnabled = isDebugEnabled();
|
|
346
|
+
const configPath = `${OPENCLAW_DIR}/openclaw.json`;
|
|
347
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
348
|
+
const telegramConfig = cfg.channels?.telegram || {};
|
|
349
|
+
const configuredGroups = telegramConfig.groups || {};
|
|
350
|
+
const groupIds = Object.keys(configuredGroups);
|
|
351
|
+
if (groupIds.length === 0) {
|
|
352
|
+
return res.json({ ok: true, configured: false, debugEnabled });
|
|
353
|
+
}
|
|
354
|
+
const groupId = String(groupIds[0]);
|
|
355
|
+
const registryGroup = topicRegistry.getGroup(groupId);
|
|
356
|
+
let groupName = registryGroup?.name || groupId;
|
|
357
|
+
try {
|
|
358
|
+
const chat = await telegramApi.getChat(groupId);
|
|
359
|
+
if (chat?.title) groupName = chat.title;
|
|
360
|
+
} catch {}
|
|
361
|
+
return res.json({
|
|
362
|
+
ok: true,
|
|
363
|
+
configured: true,
|
|
364
|
+
groupId,
|
|
365
|
+
groupName,
|
|
366
|
+
topics: registryGroup?.topics || {},
|
|
367
|
+
debugEnabled,
|
|
368
|
+
concurrency: {
|
|
369
|
+
agentMaxConcurrent: cfg.agents?.defaults?.maxConcurrent ?? null,
|
|
370
|
+
subagentMaxConcurrent: cfg.agents?.defaults?.subagents?.maxConcurrent ?? null,
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
} catch (e) {
|
|
374
|
+
return res.json({ ok: false, error: e.message });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Reset Telegram workspace onboarding state
|
|
379
|
+
app.post("/api/telegram/workspace/reset", (req, res) => {
|
|
380
|
+
try {
|
|
381
|
+
const configPath = `${OPENCLAW_DIR}/openclaw.json`;
|
|
382
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
383
|
+
const telegramGroups = Object.keys(cfg.channels?.telegram?.groups || {});
|
|
384
|
+
if (cfg.channels?.telegram) {
|
|
385
|
+
delete cfg.channels.telegram.groups;
|
|
386
|
+
delete cfg.channels.telegram.groupAllowFrom;
|
|
387
|
+
}
|
|
388
|
+
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
|
|
389
|
+
|
|
390
|
+
// Remove corresponding groups from topic registry
|
|
391
|
+
const registry = topicRegistry.readRegistry();
|
|
392
|
+
if (registry && registry.groups) {
|
|
393
|
+
for (const groupId of telegramGroups) {
|
|
394
|
+
delete registry.groups[groupId];
|
|
395
|
+
}
|
|
396
|
+
topicRegistry.writeRegistry(registry);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
syncPromptFiles();
|
|
400
|
+
return res.json({ ok: true });
|
|
401
|
+
} catch (e) {
|
|
402
|
+
return res.json({ ok: false, error: e.message });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
module.exports = { registerTelegramRoutes };
|