@splinterzzz/ouro 0.1.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/LICENSE +21 -0
- package/dashboard-dist/assets/index-ETSLKv6w.css +1 -0
- package/dashboard-dist/assets/index-UHUWnWxM.js +63 -0
- package/dashboard-dist/favicon.svg +35 -0
- package/dashboard-dist/index.html +18 -0
- package/package.json +54 -0
- package/src/commands/dashboard.js +50 -0
- package/src/commands/init.js +75 -0
- package/src/commands/listen.js +164 -0
- package/src/commands/logs.js +91 -0
- package/src/commands/start.js +158 -0
- package/src/commands/status.js +69 -0
- package/src/commands/stop.js +39 -0
- package/src/index.js +81 -0
- package/src/lib/agentBackend.js +41 -0
- package/src/lib/agents.js +251 -0
- package/src/lib/claudeCodeExec.js +213 -0
- package/src/lib/codexExec.js +204 -0
- package/src/lib/config.js +91 -0
- package/src/lib/daemon.js +235 -0
- package/src/lib/env.js +135 -0
- package/src/lib/frontmatter.js +114 -0
- package/src/lib/github.js +60 -0
- package/src/lib/intake.js +151 -0
- package/src/lib/paths.js +57 -0
- package/src/lib/runs.js +55 -0
- package/src/lib/ship.js +114 -0
- package/src/lib/store.js +188 -0
- package/src/lib/telegram.js +60 -0
- package/src/lib/worktree.js +93 -0
- package/src/server/index.js +457 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { WebSocketServer } from "ws";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { store } from "../lib/store.js";
|
|
7
|
+
import { triage, runAgent, planTicket, executeTicket, getBackendName, setBackendName } from "../lib/agentBackend.js";
|
|
8
|
+
import { createTicketWorktree, diffWorktree } from "../lib/worktree.js";
|
|
9
|
+
import { readConfig, writeConfig, getDefaultMode, setDefaultMode, getAutoShip, telegramTokenVar } from "../lib/config.js";
|
|
10
|
+
import { readEnvVars, writeEnvVars } from "../lib/env.js";
|
|
11
|
+
import { looksLikeToken, maskToken, verifyBotToken } from "../lib/telegram.js";
|
|
12
|
+
import { startService, stopService, serviceStatus, isAlive, tailLog, uptime } from "../lib/daemon.js";
|
|
13
|
+
import { shipTicket } from "../lib/ship.js";
|
|
14
|
+
import * as runs from "../lib/runs.js";
|
|
15
|
+
import {
|
|
16
|
+
listAgents,
|
|
17
|
+
getAgent,
|
|
18
|
+
getAgentRaw,
|
|
19
|
+
saveAgent,
|
|
20
|
+
saveAgentRaw,
|
|
21
|
+
createAgent,
|
|
22
|
+
deleteAgent,
|
|
23
|
+
defaultAgentId,
|
|
24
|
+
agentEvents,
|
|
25
|
+
TOOL_UNIVERSE,
|
|
26
|
+
} from "../lib/agents.js";
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
// Bundled inside the cli package itself (copied here at build time — see
|
|
30
|
+
// packages/cli's `prepublishOnly` script) so this works whether ouro is
|
|
31
|
+
// running from the monorepo, `npm link`, or a real `npm install` on
|
|
32
|
+
// someone else's machine. It must NOT depend on the dashboard package
|
|
33
|
+
// being present as a sibling folder, since that only exists in this repo.
|
|
34
|
+
const DASHBOARD_DIST = path.resolve(__dirname, "../../dashboard-dist");
|
|
35
|
+
|
|
36
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Everything the Settings screen needs to describe Telegram intake, and
|
|
40
|
+
* nothing it doesn't — the token itself never leaves this process. This API has
|
|
41
|
+
* no auth, so a masked hint is the most a GET is allowed to give up.
|
|
42
|
+
*/
|
|
43
|
+
function telegramStatus() {
|
|
44
|
+
const config = readConfig();
|
|
45
|
+
const { name: tokenVar, error: configError } = telegramTokenVar();
|
|
46
|
+
const token = process.env[tokenVar];
|
|
47
|
+
const listen = serviceStatus("listen");
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
tokenVar,
|
|
51
|
+
// A hand-edited config.json that put a token where a var name goes. The
|
|
52
|
+
// screen has to say so: the token is exposed, and nothing else here would
|
|
53
|
+
// explain why intake is off.
|
|
54
|
+
configError: configError ?? null,
|
|
55
|
+
configured: Boolean(token),
|
|
56
|
+
tokenHint: token ? maskToken(token) : null,
|
|
57
|
+
// Only a token in `.ouro/.env` survives a reboot; a shell export dies with
|
|
58
|
+
// the terminal that started the daemon. The UI has to be able to say which
|
|
59
|
+
// of the two you have, or "configured" is a promise it can't keep.
|
|
60
|
+
persisted: Boolean(readEnvVars()[tokenVar]),
|
|
61
|
+
// Whoever the token last verified as. Stale if someone hand-edits .env —
|
|
62
|
+
// "Test connection" is what re-grounds it.
|
|
63
|
+
bot: config.telegram?.bot ?? null,
|
|
64
|
+
listener: {
|
|
65
|
+
running: Boolean(listen.running),
|
|
66
|
+
pid: listen.running ? listen.pid : null,
|
|
67
|
+
uptime: listen.running ? uptime(listen.startedAt) : null,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Restarts the Telegram intake service so a token pasted into the dashboard
|
|
74
|
+
* takes effect without a trip back to the terminal. `listen` reads its token
|
|
75
|
+
* once at startup — there's nothing to hot-reload, so the restart *is* the
|
|
76
|
+
* mechanism.
|
|
77
|
+
*
|
|
78
|
+
* The child goes through lib/daemon.js like any other, so it lands in the same
|
|
79
|
+
* pid file `ouro status` reads and `ouro stop` kills. A listener started from
|
|
80
|
+
* here is not a second, invisible kind of process.
|
|
81
|
+
*/
|
|
82
|
+
async function restartListener() {
|
|
83
|
+
await stopService("listen");
|
|
84
|
+
const record = startService("listen");
|
|
85
|
+
|
|
86
|
+
// listen.js validates the token with getMe() and exits non-zero if Telegram
|
|
87
|
+
// rejects it, so "still alive a beat later" is a real signal — the same check
|
|
88
|
+
// `ouro start` makes. Skip it and we'd report "running" for a process that
|
|
89
|
+
// died half a second after we spawned it.
|
|
90
|
+
await sleep(3000);
|
|
91
|
+
if (isAlive(record.pid)) return { ok: true, pid: record.pid };
|
|
92
|
+
return { ok: false, log: tailLog("listen", 8, record.logOffset) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createServer() {
|
|
96
|
+
const app = express();
|
|
97
|
+
app.use(express.json({ limit: "1mb" }));
|
|
98
|
+
|
|
99
|
+
const server = http.createServer(app);
|
|
100
|
+
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
101
|
+
|
|
102
|
+
// An attached WebSocketServer re-emits the http server's listen errors. With
|
|
103
|
+
// no listener here, EADDRINUSE becomes an uncaught exception that kills the
|
|
104
|
+
// process with a stack trace before dashboardCommand's `server.on("error")`
|
|
105
|
+
// can print the readable "port already in use" message. Defer to that
|
|
106
|
+
// handler for listen errors; surface anything else rather than swallow it.
|
|
107
|
+
wss.on("error", (err) => {
|
|
108
|
+
if (err.code === "EADDRINUSE" || err.code === "EACCES") return;
|
|
109
|
+
console.error("[ws] server error:", err.message);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
function broadcast(payload) {
|
|
113
|
+
const msg = JSON.stringify(payload);
|
|
114
|
+
wss.clients.forEach((client) => {
|
|
115
|
+
if (client.readyState === 1) client.send(msg);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
store.on("change", broadcast);
|
|
120
|
+
// Agent files are edited from the dashboard but also, deliberately, straight
|
|
121
|
+
// on disk — both paths funnel through agentEvents so every connected client
|
|
122
|
+
// repaints either way.
|
|
123
|
+
agentEvents.on("change", broadcast);
|
|
124
|
+
|
|
125
|
+
/** Resolves which agent .md drives a ticket: explicit assignment, else default. */
|
|
126
|
+
function resolveAgent(ticket) {
|
|
127
|
+
const id = ticket.agentId ?? defaultAgentId();
|
|
128
|
+
return id ? getAgent(id) : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A ticket's own mode wins; otherwise the board-wide default from config. */
|
|
132
|
+
function resolveMode(ticket) {
|
|
133
|
+
return ticket.mode ?? getDefaultMode();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// --- tickets ---
|
|
137
|
+
|
|
138
|
+
app.get("/api/tickets", (_req, res) => {
|
|
139
|
+
res.json(store.list());
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
app.post("/api/tickets", (req, res) => {
|
|
143
|
+
const { title, body, source, agentId, mode, priority, summary } = req.body;
|
|
144
|
+
if (!title?.trim()) return res.status(400).json({ error: "title is required" });
|
|
145
|
+
res.json(store.create({ title: title.trim(), body: body ?? "", source, agentId, mode, priority, summary }));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
app.post("/api/tickets/:id/triage", async (req, res) => {
|
|
149
|
+
const ticket = store.get(req.params.id);
|
|
150
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
151
|
+
|
|
152
|
+
res.json({ started: true });
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const result = await triage({
|
|
156
|
+
prompt: `Analyze this ticket and summarize it, estimate priority, and list files likely affected.\nTitle: ${ticket.title}\nBody: ${ticket.body}`,
|
|
157
|
+
cwd: process.cwd(),
|
|
158
|
+
});
|
|
159
|
+
store.update(ticket.id, {
|
|
160
|
+
status: "triaged",
|
|
161
|
+
summary: result.summary,
|
|
162
|
+
priority: result.priority,
|
|
163
|
+
});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
store.appendLog(ticket.id, { type: "error", text: String(err) });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
app.post("/api/tickets/:id/mode", (req, res) => {
|
|
170
|
+
const { mode } = req.body; // "agent" | "human" | null (inherit board default)
|
|
171
|
+
if (mode !== null && mode !== "agent" && mode !== "human") {
|
|
172
|
+
return res.status(400).json({ error: "mode must be 'agent', 'human', or null" });
|
|
173
|
+
}
|
|
174
|
+
const ticket = store.update(req.params.id, { mode });
|
|
175
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
176
|
+
res.json(ticket);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
app.post("/api/tickets/:id/agent", (req, res) => {
|
|
180
|
+
const { agentId } = req.body;
|
|
181
|
+
if (agentId && !getAgent(agentId)) return res.status(400).json({ error: `no such agent: ${agentId}` });
|
|
182
|
+
const ticket = store.update(req.params.id, { agentId: agentId ?? null });
|
|
183
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
184
|
+
res.json(ticket);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
app.post("/api/tickets/:id/run", async (req, res) => {
|
|
188
|
+
const ticket = store.get(req.params.id);
|
|
189
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
190
|
+
if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
|
|
191
|
+
|
|
192
|
+
const mode = resolveMode(ticket);
|
|
193
|
+
const agent = resolveAgent(ticket);
|
|
194
|
+
|
|
195
|
+
let signal;
|
|
196
|
+
try {
|
|
197
|
+
signal = runs.begin(ticket.id, mode);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return res.status(409).json({ error: String(err.message || err) });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
res.json({ started: true, mode, agentId: agent?.id ?? null });
|
|
203
|
+
store.update(ticket.id, { status: "in_progress", mode, agentId: agent?.id ?? ticket.agentId, cancelReason: null });
|
|
204
|
+
|
|
205
|
+
const onEvent = (event) => store.appendLog(ticket.id, { type: "agent_event", event });
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const { dir: worktreeDir, branch, base } = await createTicketWorktree(ticket.id);
|
|
209
|
+
store.update(ticket.id, { worktree: worktreeDir, branch, baseBranch: base });
|
|
210
|
+
|
|
211
|
+
const prompt = `Ticket: ${ticket.title}\n\n${ticket.body}\n\nImplement this and run relevant tests.`;
|
|
212
|
+
|
|
213
|
+
if (mode === "agent") {
|
|
214
|
+
// Full autonomy, single call.
|
|
215
|
+
const result = await runAgent({ prompt, cwd: worktreeDir, onEvent, signal, agent });
|
|
216
|
+
if (result.aborted) return; // cancel route already marked it
|
|
217
|
+
const diff = await diffWorktree(ticket.id).catch(() => null);
|
|
218
|
+
store.update(ticket.id, { status: "review", diff, sessionId: result.sessionId, awaitingApproval: false });
|
|
219
|
+
// Agent mode is the no-pauses path: an unpushed branch in a local
|
|
220
|
+
// worktree isn't a finished ticket, so carry it through to a PR.
|
|
221
|
+
if (getAutoShip()) await shipTicket(ticket.id);
|
|
222
|
+
} else {
|
|
223
|
+
// Human-in-the-loop: plan only, no writes yet. The card shows the
|
|
224
|
+
// plan and waits for an explicit Approve before phase 2 runs.
|
|
225
|
+
const result = await planTicket({ prompt, cwd: worktreeDir, onEvent, signal, agent });
|
|
226
|
+
if (result.aborted) return;
|
|
227
|
+
store.update(ticket.id, {
|
|
228
|
+
status: "review",
|
|
229
|
+
sessionId: result.sessionId,
|
|
230
|
+
plan: result.lastMessage,
|
|
231
|
+
awaitingApproval: true,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
store.appendLog(ticket.id, { type: "error", text: String(err.message || err) });
|
|
236
|
+
store.cancel(ticket.id, `Run failed: ${err.message || err}`);
|
|
237
|
+
} finally {
|
|
238
|
+
runs.end(ticket.id);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
app.post("/api/tickets/:id/approve", async (req, res) => {
|
|
243
|
+
const ticket = store.get(req.params.id);
|
|
244
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
245
|
+
if (!ticket.awaitingApproval) return res.status(409).json({ error: "ticket is not awaiting approval" });
|
|
246
|
+
if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "already running" });
|
|
247
|
+
|
|
248
|
+
let signal;
|
|
249
|
+
try {
|
|
250
|
+
signal = runs.begin(ticket.id, "execute");
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return res.status(409).json({ error: String(err.message || err) });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
res.json({ started: true });
|
|
256
|
+
store.update(ticket.id, { status: "in_progress", awaitingApproval: false });
|
|
257
|
+
|
|
258
|
+
const onEvent = (event) => store.appendLog(ticket.id, { type: "agent_event", event });
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const result = await executeTicket({
|
|
262
|
+
cwd: ticket.worktree,
|
|
263
|
+
sessionId: ticket.sessionId,
|
|
264
|
+
onEvent,
|
|
265
|
+
signal,
|
|
266
|
+
agent: resolveAgent(ticket),
|
|
267
|
+
});
|
|
268
|
+
if (result.aborted) return;
|
|
269
|
+
const diff = await diffWorktree(ticket.id).catch(() => null);
|
|
270
|
+
store.update(ticket.id, { status: "review", diff, sessionId: result.sessionId });
|
|
271
|
+
// You already approved the plan — the PR is the point of that approval,
|
|
272
|
+
// so don't stop one step short of it.
|
|
273
|
+
if (getAutoShip()) await shipTicket(ticket.id);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
store.appendLog(ticket.id, { type: "error", text: String(err.message || err) });
|
|
276
|
+
store.cancel(ticket.id, `Execute failed: ${err.message || err}`);
|
|
277
|
+
} finally {
|
|
278
|
+
runs.end(ticket.id);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Manual ship — the button on a Review card, and the retry path when
|
|
283
|
+
// autoShip is off or a push/PR failed the first time.
|
|
284
|
+
app.post("/api/tickets/:id/ship", async (req, res) => {
|
|
285
|
+
const ticket = store.get(req.params.id);
|
|
286
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
287
|
+
if (!ticket.worktree) return res.status(409).json({ error: "this ticket has never run — nothing to ship" });
|
|
288
|
+
if (runs.isRunning(ticket.id)) return res.status(409).json({ error: "still running" });
|
|
289
|
+
|
|
290
|
+
const result = await shipTicket(ticket.id);
|
|
291
|
+
if (!result.ok && result.error) return res.status(422).json(result);
|
|
292
|
+
res.json(result);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
app.post("/api/tickets/:id/cancel", (req, res) => {
|
|
296
|
+
const ticket = store.get(req.params.id);
|
|
297
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
298
|
+
|
|
299
|
+
// Cancelling a queued/awaiting ticket is just as valid as killing a live
|
|
300
|
+
// run — there may be no child process to signal, and that's not an error.
|
|
301
|
+
const killed = runs.cancel(ticket.id);
|
|
302
|
+
const reason = req.body?.reason ?? (killed ? "Run cancelled from the dashboard." : "Cancelled from the dashboard.");
|
|
303
|
+
store.appendLog(ticket.id, { type: "cancelled", text: reason });
|
|
304
|
+
res.json(store.cancel(ticket.id, reason));
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
app.post("/api/tickets/:id/reopen", (req, res) => {
|
|
308
|
+
const ticket = store.reopen(req.params.id);
|
|
309
|
+
if (!ticket) return res.status(404).json({ error: "not found" });
|
|
310
|
+
res.json(ticket);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
app.delete("/api/tickets/:id", (req, res) => {
|
|
314
|
+
runs.cancel(req.params.id); // don't leave an orphaned child behind
|
|
315
|
+
if (!store.remove(req.params.id)) return res.status(404).json({ error: "not found" });
|
|
316
|
+
res.json({ deleted: true });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
app.get("/api/runs", (_req, res) => {
|
|
320
|
+
res.json(runs.activeRuns());
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// --- agents (backed by .ouro/agents/*.md) ---
|
|
324
|
+
|
|
325
|
+
app.get("/api/agents", (_req, res) => {
|
|
326
|
+
res.json({ agents: listAgents(), toolUniverse: TOOL_UNIVERSE, defaultAgentId: defaultAgentId() });
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
app.post("/api/agents", (req, res) => {
|
|
330
|
+
const { name } = req.body;
|
|
331
|
+
if (!name?.trim()) return res.status(400).json({ error: "name is required" });
|
|
332
|
+
res.json(createAgent({ name: name.trim() }));
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
app.get("/api/agents/:id", (req, res) => {
|
|
336
|
+
const agent = getAgent(req.params.id);
|
|
337
|
+
if (!agent) return res.status(404).json({ error: "not found" });
|
|
338
|
+
res.json({ ...agent, raw: getAgentRaw(req.params.id) });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
app.patch("/api/agents/:id", (req, res) => {
|
|
342
|
+
try {
|
|
343
|
+
// `raw` replaces the whole file; anything else is a structured field
|
|
344
|
+
// patch. The dashboard offers both, so the route accepts both.
|
|
345
|
+
const agent = req.body.raw !== undefined
|
|
346
|
+
? saveAgentRaw(req.params.id, req.body.raw)
|
|
347
|
+
: saveAgent(req.params.id, req.body);
|
|
348
|
+
if (!agent) return res.status(404).json({ error: "not found" });
|
|
349
|
+
res.json({ ...agent, raw: getAgentRaw(req.params.id) });
|
|
350
|
+
} catch (err) {
|
|
351
|
+
res.status(400).json({ error: String(err.message || err) });
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
app.delete("/api/agents/:id", (req, res) => {
|
|
356
|
+
if (!deleteAgent(req.params.id)) return res.status(404).json({ error: "not found" });
|
|
357
|
+
res.json({ deleted: true });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// --- config (backend + default mode) ---
|
|
361
|
+
|
|
362
|
+
app.get("/api/config", (_req, res) => {
|
|
363
|
+
const config = readConfig();
|
|
364
|
+
// `pid` is here so `ouro start` can tell *this* server apart from another
|
|
365
|
+
// ouro already holding the port. Without it, a probe that gets a 200 only
|
|
366
|
+
// proves someone is listening — not that the process we just spawned is
|
|
367
|
+
// the one answering.
|
|
368
|
+
res.json({ backend: getBackendName(), defaultMode: getDefaultMode(), autoShip: getAutoShip(), pid: process.pid, config });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
app.post("/api/config/backend", (req, res) => {
|
|
372
|
+
try {
|
|
373
|
+
res.json({ backend: setBackendName(req.body.backend) });
|
|
374
|
+
} catch (err) {
|
|
375
|
+
res.status(400).json({ error: String(err.message || err) });
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
app.post("/api/config/mode", (req, res) => {
|
|
380
|
+
try {
|
|
381
|
+
const mode = setDefaultMode(req.body.mode);
|
|
382
|
+
broadcast({ type: "config", defaultMode: mode });
|
|
383
|
+
res.json({ defaultMode: mode });
|
|
384
|
+
} catch (err) {
|
|
385
|
+
res.status(400).json({ error: String(err.message || err) });
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// --- telegram credentials ---
|
|
390
|
+
//
|
|
391
|
+
// Pasting a token here does what the README used to ask you to do by hand:
|
|
392
|
+
// write `.ouro/.env`, then `ouro restart`. Both steps are the same ones the
|
|
393
|
+
// CLI takes (lib/env.js, lib/daemon.js), so a token saved from the dashboard
|
|
394
|
+
// and one echoed into the file are indistinguishable afterwards — this screen
|
|
395
|
+
// is a front end for that file, not a second place credentials can live.
|
|
396
|
+
|
|
397
|
+
app.get("/api/config/telegram", (_req, res) => {
|
|
398
|
+
res.json(telegramStatus());
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
app.post("/api/config/telegram", async (req, res) => {
|
|
402
|
+
const token = String(req.body?.botToken ?? "").trim();
|
|
403
|
+
if (!token) return res.status(400).json({ error: "botToken is required" });
|
|
404
|
+
if (!looksLikeToken(token)) {
|
|
405
|
+
return res.status(400).json({
|
|
406
|
+
error: "That doesn't look like a bot token. @BotFather issues them as 123456789:AA… — a bot id, a colon, then a 35-character secret.",
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Verify before writing. A token that only fails later fails inside a
|
|
411
|
+
// detached background process, where the evidence is a log file nobody is
|
|
412
|
+
// tailing — so the round trip to Telegram happens while someone is still
|
|
413
|
+
// looking at the screen that caused it.
|
|
414
|
+
const check = await verifyBotToken(token);
|
|
415
|
+
if (!check.ok) return res.status(422).json({ error: check.error });
|
|
416
|
+
|
|
417
|
+
const { name: tokenVar } = telegramTokenVar();
|
|
418
|
+
writeEnvVars({ [tokenVar]: token });
|
|
419
|
+
// Shallow-merge by hand: writeConfig replaces top-level keys, so patching
|
|
420
|
+
// `telegram` with a bare { bot } would drop botTokenEnvVar with it.
|
|
421
|
+
writeConfig({ telegram: { ...readConfig().telegram, bot: check.bot } });
|
|
422
|
+
|
|
423
|
+
const restart = await restartListener();
|
|
424
|
+
res.json({ ...telegramStatus(), restart });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
app.post("/api/config/telegram/test", async (_req, res) => {
|
|
428
|
+
const { name: tokenVar } = telegramTokenVar();
|
|
429
|
+
const token = process.env[tokenVar];
|
|
430
|
+
if (!token) return res.status(409).json({ error: `${tokenVar} isn't set — save a token first.` });
|
|
431
|
+
|
|
432
|
+
const check = await verifyBotToken(token);
|
|
433
|
+
if (!check.ok) return res.status(422).json({ error: check.error });
|
|
434
|
+
|
|
435
|
+
writeConfig({ telegram: { ...readConfig().telegram, bot: check.bot } });
|
|
436
|
+
res.json({ ...telegramStatus(), verified: true });
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
app.delete("/api/config/telegram", async (_req, res) => {
|
|
440
|
+
// Stop first: the listener holds the old token in memory, so leaving it up
|
|
441
|
+
// would mean a bot still answering strangers with a credential the
|
|
442
|
+
// dashboard now says is gone.
|
|
443
|
+
await stopService("listen");
|
|
444
|
+
writeEnvVars({ [telegramTokenVar().name]: null });
|
|
445
|
+
const { bot, ...telegram } = readConfig().telegram ?? {};
|
|
446
|
+
writeConfig({ telegram });
|
|
447
|
+
res.json(telegramStatus());
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// --- static dashboard ---
|
|
451
|
+
app.use(express.static(DASHBOARD_DIST));
|
|
452
|
+
app.get("*", (_req, res) => {
|
|
453
|
+
res.sendFile(path.join(DASHBOARD_DIST, "index.html"));
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
return server;
|
|
457
|
+
}
|