@worca/ui 0.9.0 → 0.11.0-rc.1
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/app/main.bundle.js +895 -813
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +216 -9
- package/app/utils/state-actions.js +55 -0
- package/package.json +6 -4
- package/server/app.js +291 -6
- package/server/beads-reader.js +1 -1
- package/server/dispatch-external.js +106 -0
- package/server/ensure-webhook.js +66 -0
- package/server/index.js +22 -0
- package/server/integrations/adapter.js +91 -0
- package/server/integrations/adapters/discord.js +109 -0
- package/server/integrations/adapters/slack.js +106 -0
- package/server/integrations/adapters/telegram.js +231 -0
- package/server/integrations/adapters/webhook_out.js +253 -0
- package/server/integrations/allowlist.js +19 -0
- package/server/integrations/chat_context.js +68 -0
- package/server/integrations/commands/control.js +120 -0
- package/server/integrations/commands/global.js +239 -0
- package/server/integrations/commands/parser.js +29 -0
- package/server/integrations/commands/project.js +394 -0
- package/server/integrations/config-loader.js +40 -0
- package/server/integrations/index.js +390 -0
- package/server/integrations/markdown.js +220 -0
- package/server/integrations/rate_limiter.js +131 -0
- package/server/integrations/renderers.js +191 -0
- package/server/integrations/rest_client.js +17 -0
- package/server/integrations/verify.js +23 -0
- package/server/process-manager.js +217 -14
- package/server/project-routes.js +210 -44
- package/server/settings-validator.js +250 -0
- package/server/ws-beads-watcher.js +22 -6
- package/server/ws-message-router.js +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string[]} allowedIds — chat IDs permitted to send inbound messages
|
|
3
|
+
* @param {{ debug?: (...args: unknown[]) => void }} [log]
|
|
4
|
+
* @returns {{ isAllowed: (msg: { platform: string, chatId: string }) => boolean }}
|
|
5
|
+
*/
|
|
6
|
+
export function createAllowlistGuard(allowedIds, log = {}) {
|
|
7
|
+
const set = new Set(allowedIds);
|
|
8
|
+
const debug = log.debug ?? (() => {});
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
isAllowed({ platform, chatId }) {
|
|
12
|
+
if (set.has(chatId)) return true;
|
|
13
|
+
debug(
|
|
14
|
+
`[allowlist] drop inbound message — platform=${platform} chatId=${chatId} not in allowlist`,
|
|
15
|
+
);
|
|
16
|
+
return false;
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const SCHEMA_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CHAT_STATE = {
|
|
7
|
+
active_project: null,
|
|
8
|
+
mute_until: null,
|
|
9
|
+
muted_messages: 0,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} filePath absolute path to chat_context.json
|
|
14
|
+
* @returns {{ get, set, isMuted, incrementMuted }}
|
|
15
|
+
*/
|
|
16
|
+
export function createChatContext(filePath) {
|
|
17
|
+
const data = _load(filePath);
|
|
18
|
+
|
|
19
|
+
function get(chatKey) {
|
|
20
|
+
return { ...DEFAULT_CHAT_STATE, ...data.chats[chatKey] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function set(chatKey, patch) {
|
|
24
|
+
data.chats[chatKey] = {
|
|
25
|
+
...DEFAULT_CHAT_STATE,
|
|
26
|
+
...data.chats[chatKey],
|
|
27
|
+
...patch,
|
|
28
|
+
};
|
|
29
|
+
_save(filePath, data);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isMuted(chatKey) {
|
|
33
|
+
const { mute_until } = get(chatKey);
|
|
34
|
+
if (!mute_until) return false;
|
|
35
|
+
return new Date(mute_until) > new Date();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function incrementMuted(chatKey) {
|
|
39
|
+
const current = get(chatKey);
|
|
40
|
+
set(chatKey, { muted_messages: current.muted_messages + 1 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { get, set, isMuted, incrementMuted };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _load(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
49
|
+
if (
|
|
50
|
+
raw &&
|
|
51
|
+
typeof raw === 'object' &&
|
|
52
|
+
raw.chats &&
|
|
53
|
+
typeof raw.chats === 'object'
|
|
54
|
+
) {
|
|
55
|
+
return raw;
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// file missing or invalid — start fresh
|
|
59
|
+
}
|
|
60
|
+
return { schema_version: SCHEMA_VERSION, chats: {} };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _save(filePath, data) {
|
|
64
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
65
|
+
const tmp = `${filePath}.tmp`;
|
|
66
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
67
|
+
renameSync(tmp, filePath);
|
|
68
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { statusEmoji } from './global.js';
|
|
2
|
+
|
|
3
|
+
const NO_ACTIVE_PROJECT =
|
|
4
|
+
'No active project. Use `/projects` to list, `/use <name>` to select.';
|
|
5
|
+
|
|
6
|
+
function matchRunIdPattern(pattern, runs, command) {
|
|
7
|
+
if (!pattern) return null;
|
|
8
|
+
const isWildcard = pattern.startsWith('*');
|
|
9
|
+
const suffix = isWildcard ? pattern.slice(1) : null;
|
|
10
|
+
if (!isWildcard) return { runId: pattern };
|
|
11
|
+
if (!suffix) return null;
|
|
12
|
+
const matches = runs.filter((r) => (r.id ?? r.run_id ?? '').endsWith(suffix));
|
|
13
|
+
if (matches.length === 1)
|
|
14
|
+
return { runId: matches[0].id ?? matches[0].run_id };
|
|
15
|
+
if (matches.length > 1) {
|
|
16
|
+
const lines = matches.map((r) => {
|
|
17
|
+
const id = r.id ?? r.run_id;
|
|
18
|
+
const ps = r.pipeline_status || (r.active ? 'running' : 'unknown');
|
|
19
|
+
const title = r.work_request?.title;
|
|
20
|
+
const parts = [`${statusEmoji(ps)} **Run:** \`${id}\``];
|
|
21
|
+
if (title) parts.push(` **Title:** ${title}`);
|
|
22
|
+
return parts.join('\n');
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
disambig: `Multiple runs match \`*${suffix}\`:\n\n${lines.join('\n')}\n\nUsage: /${command} <run_id>`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return { runId: pattern };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function resolveRunId(restClient, projectId, args, command) {
|
|
32
|
+
const resp = await restClient.get(
|
|
33
|
+
`/api/projects/${encodeURIComponent(projectId)}/runs`,
|
|
34
|
+
);
|
|
35
|
+
const runs = resp.data?.runs ?? (Array.isArray(resp.data) ? resp.data : []);
|
|
36
|
+
if (args[0]) {
|
|
37
|
+
const matched = matchRunIdPattern(args[0], runs, command);
|
|
38
|
+
if (matched) return matched;
|
|
39
|
+
}
|
|
40
|
+
const active = runs.filter((r) => {
|
|
41
|
+
const ps = r.pipeline_status || (r.active ? 'running' : null);
|
|
42
|
+
return ps === 'running' || ps === 'paused' || ps === 'resuming';
|
|
43
|
+
});
|
|
44
|
+
if (active.length === 1) return { runId: active[0].id ?? active[0].run_id };
|
|
45
|
+
if (active.length > 1) {
|
|
46
|
+
const lines = active.map((r) => {
|
|
47
|
+
const id = r.id ?? r.run_id;
|
|
48
|
+
const ps = r.pipeline_status || (r.active ? 'running' : 'unknown');
|
|
49
|
+
const title = r.work_request?.title;
|
|
50
|
+
const parts = [`${statusEmoji(ps)} **Run:** \`${id}\``];
|
|
51
|
+
if (title) parts.push(` **Title:** ${title}`);
|
|
52
|
+
return parts.join('\n');
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
disambig: `Multiple active runs \u2014 specify a run ID:\n\n${lines.join('\n')}\n\nUsage: /${command} <run_id>`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { runId: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function invokeControl(restClient, projectId, runId, action) {
|
|
62
|
+
const resp = await restClient.post(
|
|
63
|
+
`/api/projects/${encodeURIComponent(projectId)}/runs/${encodeURIComponent(runId)}/${action}`,
|
|
64
|
+
);
|
|
65
|
+
return resp;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ACTION_EMOJI = {
|
|
69
|
+
pause: '\u{1F7E1}',
|
|
70
|
+
resume: '\u{1F7E2}',
|
|
71
|
+
stop: '\u{1F534}',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const ACTION_PAST = {
|
|
75
|
+
pause: 'Paused',
|
|
76
|
+
resume: 'Resumed',
|
|
77
|
+
stop: 'Stopped',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Creates handlers for control commands: /pause /resume /stop.
|
|
82
|
+
*
|
|
83
|
+
* @param {{ chatContext, restClient }} deps
|
|
84
|
+
* @returns {Record<string, (chatKey: string, args: string[]) => Promise<string>>}
|
|
85
|
+
*/
|
|
86
|
+
export function createControlHandlers({ chatContext, restClient }) {
|
|
87
|
+
function requireProject(chatKey) {
|
|
88
|
+
const { active_project } = chatContext.get(chatKey);
|
|
89
|
+
return active_project ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function makeControlHandler(chatKey, args, action) {
|
|
93
|
+
const project = requireProject(chatKey);
|
|
94
|
+
if (!project) return NO_ACTIVE_PROJECT;
|
|
95
|
+
|
|
96
|
+
const resolved = await resolveRunId(restClient, project, args, action);
|
|
97
|
+
if (resolved.disambig) return resolved.disambig;
|
|
98
|
+
const runId = resolved.runId;
|
|
99
|
+
if (!runId) return 'No active run found.\nUse /runs to see recent runs.';
|
|
100
|
+
|
|
101
|
+
const resp = await invokeControl(restClient, project, runId, action);
|
|
102
|
+
if (!resp.data)
|
|
103
|
+
return `Failed to ${action} run "${runId}" (${resp.status}).`;
|
|
104
|
+
return `${ACTION_EMOJI[action]} ${ACTION_PAST[action]} run: \`${runId}\``;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function pause(chatKey, args) {
|
|
108
|
+
return makeControlHandler(chatKey, args, 'pause');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function resume(chatKey, args) {
|
|
112
|
+
return makeControlHandler(chatKey, args, 'resume');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function stop(chatKey, args) {
|
|
116
|
+
return makeControlHandler(chatKey, args, 'stop');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { pause, resume, stop };
|
|
120
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { readProjects } from '../../project-registry.js';
|
|
2
|
+
|
|
3
|
+
const UNITS_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a duration string like "30m", "1h", "2d" into milliseconds.
|
|
7
|
+
* Returns null if unrecognized.
|
|
8
|
+
*/
|
|
9
|
+
export function parseDuration(str) {
|
|
10
|
+
if (!str) return null;
|
|
11
|
+
const match = /^(\d+)([smhd])$/i.exec(str);
|
|
12
|
+
if (!match) return null;
|
|
13
|
+
return Number(match[1]) * UNITS_MS[match[2].toLowerCase()];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STATUS_EMOJI = {
|
|
17
|
+
running: '\u{1F7E2}',
|
|
18
|
+
resuming: '\u{1F7E2}',
|
|
19
|
+
failed: '\u{1F534}',
|
|
20
|
+
stopped: '\u{1F534}',
|
|
21
|
+
paused: '\u{1F7E1}',
|
|
22
|
+
completed: '\u2705',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Map a pipeline_status string to its emoji.
|
|
27
|
+
*/
|
|
28
|
+
export function statusEmoji(ps) {
|
|
29
|
+
return STATUS_EMOJI[ps] || '\u26AA';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function chatIdOnly(chatKey) {
|
|
33
|
+
// chatKey is "platform:id" — return just the id
|
|
34
|
+
const idx = chatKey.indexOf(':');
|
|
35
|
+
return idx >= 0 ? chatKey.slice(idx + 1) : chatKey;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fmtCostFromStages(stages) {
|
|
39
|
+
let totalCost = 0;
|
|
40
|
+
for (const stage of Object.values(stages || {})) {
|
|
41
|
+
for (const iter of stage.iterations || []) {
|
|
42
|
+
totalCost += iter.cost_usd || 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return totalCost > 0 ? `$${totalCost.toFixed(2)}` : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const TERMINAL_STATUSES = new Set([
|
|
49
|
+
'completed',
|
|
50
|
+
'failed',
|
|
51
|
+
'interrupted',
|
|
52
|
+
'stopped',
|
|
53
|
+
'cancelled',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
function fmtMs(ms) {
|
|
57
|
+
const totalSec = Math.floor(ms / 1000);
|
|
58
|
+
const m = Math.floor(totalSec / 60);
|
|
59
|
+
const s = totalSec % 60;
|
|
60
|
+
return m > 0 ? `${m}m${String(s).padStart(2, '0')}s` : `${s}s`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compute run duration from stage iteration timestamps. Uses run-level
|
|
65
|
+
* completed_at when available, otherwise derives from the latest iteration.
|
|
66
|
+
* For running pipelines, shows live elapsed.
|
|
67
|
+
*/
|
|
68
|
+
function fmtElapsedFromRun(run) {
|
|
69
|
+
if (!run?.started_at) return null;
|
|
70
|
+
const startedAt = run.started_at;
|
|
71
|
+
const completedAt = run.completed_at;
|
|
72
|
+
|
|
73
|
+
if (completedAt) {
|
|
74
|
+
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
|
75
|
+
return ms >= 0 ? fmtMs(ms) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ps = run.pipeline_status || (run.active ? 'running' : 'unknown');
|
|
79
|
+
if (TERMINAL_STATUSES.has(ps)) {
|
|
80
|
+
let lastEnd = null;
|
|
81
|
+
for (const stage of Object.values(run.stages || {})) {
|
|
82
|
+
for (const iter of stage.iterations || []) {
|
|
83
|
+
if (iter.completed_at) {
|
|
84
|
+
const t = new Date(iter.completed_at).getTime();
|
|
85
|
+
if (!lastEnd || t > lastEnd) lastEnd = t;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (lastEnd) {
|
|
90
|
+
const ms = lastEnd - new Date(startedAt).getTime();
|
|
91
|
+
return ms >= 0 ? fmtMs(ms) : null;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ms = Date.now() - new Date(startedAt).getTime();
|
|
97
|
+
return ms >= 0 ? fmtMs(ms) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const HELP_TEXT = `/start \u2014 show your chat ID
|
|
101
|
+
/help \u2014 this list
|
|
102
|
+
/whoami \u2014 chat ID, active project, mute state
|
|
103
|
+
/projects \u2014 list registered projects
|
|
104
|
+
/use <project> \u2014 set active project
|
|
105
|
+
/active \u2014 running pipelines across all projects
|
|
106
|
+
/mute [duration] \u2014 silence notifications (e.g. /mute 1h)
|
|
107
|
+
/unmute \u2014 restore notifications
|
|
108
|
+
/status [run_id] \u2014 run status
|
|
109
|
+
/runs [N] \u2014 recent runs
|
|
110
|
+
/last \u2014 most recent run
|
|
111
|
+
/cost [run_id] \u2014 cost summary
|
|
112
|
+
/pr [run_id] \u2014 PR URL
|
|
113
|
+
/error [run_id] \u2014 show failure details
|
|
114
|
+
/pause [run_id] \u2014 pause active run
|
|
115
|
+
/resume [run_id] \u2014 resume paused run
|
|
116
|
+
/stop [run_id] \u2014 stop run
|
|
117
|
+
|
|
118
|
+
Commands with [run_id] auto-resolve to the active run if omitted.
|
|
119
|
+
Use \`*suffix\` to match by ending, e.g. /status \`*2db5\`
|
|
120
|
+
Project commands require /use first.`;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Creates handlers for global (non-project-scoped) commands.
|
|
124
|
+
*
|
|
125
|
+
* @param {{ chatContext, prefsDir: string, restClient }} deps
|
|
126
|
+
* @returns {Record<string, (chatKey: string, args: string[]) => Promise<string>>}
|
|
127
|
+
*/
|
|
128
|
+
export function createGlobalHandlers({ chatContext, prefsDir, restClient }) {
|
|
129
|
+
async function start(chatKey) {
|
|
130
|
+
return `**Chat ID:** \`${chatIdOnly(chatKey)}\``;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function help() {
|
|
134
|
+
return HELP_TEXT;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function whoami(chatKey) {
|
|
138
|
+
const state = chatContext.get(chatKey);
|
|
139
|
+
const active = state.active_project ?? '(none)';
|
|
140
|
+
const muted = chatContext.isMuted(chatKey) ? 'yes' : 'no';
|
|
141
|
+
return `**Chat ID:** \`${chatIdOnly(chatKey)}\`\n**Active project:** ${active}\n**Muted:** ${muted}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function projects() {
|
|
145
|
+
const list = readProjects(prefsDir);
|
|
146
|
+
if (list.length === 0) return 'No projects registered.';
|
|
147
|
+
return `Registered projects:\n${list.map((p) => `\u2022 ${p.name} \u2014 ${p.path}`).join('\n')}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function use(chatKey, args) {
|
|
151
|
+
const name = args[0];
|
|
152
|
+
if (!name) return 'Usage: /use <project>';
|
|
153
|
+
const list = readProjects(prefsDir);
|
|
154
|
+
const found = list.find((p) => p.name === name);
|
|
155
|
+
if (!found) {
|
|
156
|
+
const known = list.map((p) => p.name).join(', ') || '(none)';
|
|
157
|
+
return `Project "${name}" not found.\nKnown projects: ${known}`;
|
|
158
|
+
}
|
|
159
|
+
chatContext.set(chatKey, { active_project: name });
|
|
160
|
+
return `**Active project** set to: ${name}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function active(chatKey) {
|
|
164
|
+
const list = readProjects(prefsDir);
|
|
165
|
+
if (list.length === 0) return 'No projects registered.';
|
|
166
|
+
|
|
167
|
+
// Auto-select when exactly one project is registered and none is active
|
|
168
|
+
if (list.length === 1 && !chatContext.get(chatKey).active_project) {
|
|
169
|
+
chatContext.set(chatKey, { active_project: list[0].name });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const lines = [];
|
|
173
|
+
for (const project of list) {
|
|
174
|
+
const resp = await restClient.get(
|
|
175
|
+
`/api/projects/${encodeURIComponent(project.name)}/runs`,
|
|
176
|
+
);
|
|
177
|
+
const runs =
|
|
178
|
+
resp.data?.runs ?? (Array.isArray(resp.data) ? resp.data : []);
|
|
179
|
+
for (const run of runs) {
|
|
180
|
+
const ps = run.pipeline_status || (run.active ? 'running' : null);
|
|
181
|
+
if (ps === 'running' || ps === 'paused' || ps === 'resuming') {
|
|
182
|
+
const id = run.id ?? run.run_id;
|
|
183
|
+
const title = run.work_request?.title;
|
|
184
|
+
const stage = run.stage;
|
|
185
|
+
const elapsed = fmtElapsedFromRun(run);
|
|
186
|
+
const parts = [`${statusEmoji(ps)} **Run:** \`${id}\``];
|
|
187
|
+
parts.push(` **Project:** ${project.name}`);
|
|
188
|
+
if (title) parts.push(` **Title:** ${title}`);
|
|
189
|
+
if (stage && elapsed) {
|
|
190
|
+
parts.push(` **Stage:** ${stage} | **Duration:** ${elapsed}`);
|
|
191
|
+
} else if (stage) {
|
|
192
|
+
parts.push(` **Stage:** ${stage}`);
|
|
193
|
+
} else if (elapsed) {
|
|
194
|
+
parts.push(` **Duration:** ${elapsed}`);
|
|
195
|
+
}
|
|
196
|
+
lines.push(parts.join('\n'));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (lines.length === 0) return 'No active pipelines across any project.';
|
|
201
|
+
return `Active pipelines:\n\n${lines.join('\n')}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function mute(chatKey, args) {
|
|
205
|
+
const durStr = args[0];
|
|
206
|
+
let mute_until;
|
|
207
|
+
if (durStr) {
|
|
208
|
+
const ms = parseDuration(durStr);
|
|
209
|
+
if (!ms)
|
|
210
|
+
return `Unrecognized duration "${durStr}". Use e.g. 30m, 1h, 2d.`;
|
|
211
|
+
mute_until = new Date(Date.now() + ms).toISOString();
|
|
212
|
+
} else {
|
|
213
|
+
mute_until = new Date(Date.now() + 365 * 86_400_000).toISOString();
|
|
214
|
+
}
|
|
215
|
+
chatContext.set(chatKey, { mute_until });
|
|
216
|
+
return durStr
|
|
217
|
+
? `Notifications muted for ${durStr}.`
|
|
218
|
+
: 'Notifications muted indefinitely.\nUse /unmute to restore.';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function unmute(chatKey) {
|
|
222
|
+
chatContext.set(chatKey, { mute_until: null });
|
|
223
|
+
return 'Notifications restored.';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
start,
|
|
228
|
+
help,
|
|
229
|
+
whoami,
|
|
230
|
+
projects,
|
|
231
|
+
use,
|
|
232
|
+
active,
|
|
233
|
+
mute,
|
|
234
|
+
unmute,
|
|
235
|
+
// Exported for reuse by project commands
|
|
236
|
+
_fmtCostFromStages: fmtCostFromStages,
|
|
237
|
+
_fmtElapsedFromRun: fmtElapsedFromRun,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const MENTION_RE = /^@\S+$/i;
|
|
2
|
+
|
|
3
|
+
const COMMAND_RE = /^\/([a-z_]+)(?:@\S+)?$/i;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parses a chat message into a command name and argument list.
|
|
7
|
+
* Strips bot @mentions (anywhere in the text) and handles the
|
|
8
|
+
* Telegram /command@botname suffix convention.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} text
|
|
11
|
+
* @returns {{ command: string, args: string[] } | null}
|
|
12
|
+
*/
|
|
13
|
+
export function parseCommand(text) {
|
|
14
|
+
if (!text || !text.trim()) return null;
|
|
15
|
+
|
|
16
|
+
const tokens = text.trim().split(/\s+/);
|
|
17
|
+
|
|
18
|
+
const filtered = tokens.filter((t) => !MENTION_RE.test(t));
|
|
19
|
+
|
|
20
|
+
if (filtered.length === 0) return null;
|
|
21
|
+
|
|
22
|
+
const match = COMMAND_RE.exec(filtered[0]);
|
|
23
|
+
if (!match) return null;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
command: match[1].toLowerCase(),
|
|
27
|
+
args: filtered.slice(1),
|
|
28
|
+
};
|
|
29
|
+
}
|