skimpyclaw 0.3.14 → 0.4.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/README.md +47 -37
- package/dist/__tests__/adapter-types.test.d.ts +4 -0
- package/dist/__tests__/adapter-types.test.js +63 -0
- package/dist/__tests__/anthropic-adapter.test.d.ts +4 -0
- package/dist/__tests__/anthropic-adapter.test.js +264 -0
- package/dist/__tests__/api.test.js +0 -1
- package/dist/__tests__/cli.integration.test.js +2 -4
- package/dist/__tests__/cli.test.js +0 -1
- package/dist/__tests__/code-agents-notifications.test.js +137 -0
- package/dist/__tests__/code-agents-parser.test.js +19 -1
- package/dist/__tests__/code-agents-preflight.test.js +3 -28
- package/dist/__tests__/code-agents-utils.test.js +34 -9
- package/dist/__tests__/code-agents-worktrees.test.js +116 -0
- package/dist/__tests__/codex-adapter.test.js +184 -0
- package/dist/__tests__/codex-auth.test.js +66 -0
- package/dist/__tests__/codex-provider-gating.test.js +35 -0
- package/dist/__tests__/codex-unified-loop.test.js +111 -0
- package/dist/__tests__/config-security.test.js +127 -0
- package/dist/__tests__/config.test.js +23 -0
- package/dist/__tests__/context-manager.test.js +243 -164
- package/dist/__tests__/cron-run.test.js +250 -0
- package/dist/__tests__/cron.test.js +12 -38
- package/dist/__tests__/digests.test.js +67 -0
- package/dist/__tests__/discord-attachments.test.js +211 -0
- package/dist/__tests__/discord-docs.test.d.ts +1 -0
- package/dist/__tests__/discord-docs.test.js +27 -0
- package/dist/__tests__/discord-thread-agents.test.d.ts +1 -0
- package/dist/__tests__/discord-thread-agents.test.js +115 -0
- package/dist/__tests__/discord-thread-context.test.d.ts +1 -0
- package/dist/__tests__/discord-thread-context.test.js +42 -0
- package/dist/__tests__/doctor.formatters.test.js +4 -4
- package/dist/__tests__/doctor.index.test.js +1 -1
- package/dist/__tests__/doctor.runner.test.js +3 -15
- package/dist/__tests__/env-sanitizer.test.d.ts +1 -0
- package/dist/__tests__/env-sanitizer.test.js +45 -0
- package/dist/__tests__/exec-approval.test.js +61 -0
- package/dist/__tests__/fetch-tool.test.d.ts +1 -0
- package/dist/__tests__/fetch-tool.test.js +85 -0
- package/dist/__tests__/gateway-status-auth.test.d.ts +1 -0
- package/dist/__tests__/gateway-status-auth.test.js +72 -0
- package/dist/__tests__/heartbeat.test.js +3 -3
- package/dist/__tests__/interactive-sessions.test.d.ts +1 -0
- package/dist/__tests__/interactive-sessions.test.js +96 -0
- package/dist/__tests__/langfuse.test.js +6 -18
- package/dist/__tests__/model-selection.test.js +3 -4
- package/dist/__tests__/providers-init.test.js +2 -8
- package/dist/__tests__/providers-routing.test.js +1 -1
- package/dist/__tests__/providers-utils.test.js +13 -3
- package/dist/__tests__/sessions.test.js +14 -10
- package/dist/__tests__/setup.test.js +12 -29
- package/dist/__tests__/skills.test.js +10 -7
- package/dist/__tests__/stream-formatter.test.d.ts +1 -0
- package/dist/__tests__/stream-formatter.test.js +114 -0
- package/dist/__tests__/token-efficiency.test.js +131 -15
- package/dist/__tests__/tool-loop.test.d.ts +4 -0
- package/dist/__tests__/tool-loop.test.js +505 -0
- package/dist/__tests__/tools.test.js +101 -276
- package/dist/__tests__/utils.test.d.ts +1 -0
- package/dist/__tests__/utils.test.js +14 -0
- package/dist/__tests__/voice.test.js +21 -0
- package/dist/agent.js +35 -4
- package/dist/api.js +113 -37
- package/dist/channels/discord/attachments.d.ts +50 -0
- package/dist/channels/discord/attachments.js +137 -0
- package/dist/channels/discord/delegation.d.ts +5 -0
- package/dist/channels/discord/delegation.js +136 -0
- package/dist/channels/discord/handlers.js +694 -7
- package/dist/channels/discord/index.d.ts +16 -1
- package/dist/channels/discord/index.js +64 -1
- package/dist/channels/discord/thread-agents.d.ts +54 -0
- package/dist/channels/discord/thread-agents.js +323 -0
- package/dist/channels/discord/threads.d.ts +58 -0
- package/dist/channels/discord/threads.js +192 -0
- package/dist/channels/discord/types.js +4 -2
- package/dist/channels/discord/utils.d.ts +16 -0
- package/dist/channels/discord/utils.js +86 -6
- package/dist/channels/telegram/index.d.ts +1 -1
- package/dist/channels/telegram/types.js +1 -1
- package/dist/channels/telegram/utils.js +9 -3
- package/dist/channels.d.ts +1 -1
- package/dist/cli.js +20 -400
- package/dist/code-agents/executor.d.ts +1 -1
- package/dist/code-agents/executor.js +101 -45
- package/dist/code-agents/index.d.ts +2 -7
- package/dist/code-agents/index.js +111 -80
- package/dist/code-agents/interactive-resume.d.ts +6 -0
- package/dist/code-agents/interactive-resume.js +98 -0
- package/dist/code-agents/interactive-sessions.d.ts +20 -0
- package/dist/code-agents/interactive-sessions.js +132 -0
- package/dist/code-agents/parser.js +5 -1
- package/dist/code-agents/registry.d.ts +7 -1
- package/dist/code-agents/registry.js +11 -23
- package/dist/code-agents/stream-formatter.d.ts +8 -0
- package/dist/code-agents/stream-formatter.js +92 -0
- package/dist/code-agents/types.d.ts +16 -24
- package/dist/code-agents/utils.d.ts +35 -11
- package/dist/code-agents/utils.js +349 -95
- package/dist/code-agents/worktrees.d.ts +37 -0
- package/dist/code-agents/worktrees.js +116 -0
- package/dist/config.d.ts +2 -4
- package/dist/config.js +123 -23
- package/dist/cron.d.ts +1 -6
- package/dist/cron.js +175 -82
- package/dist/dashboard/assets/index-B345aOO-.js +65 -0
- package/dist/dashboard/assets/index-ZWK4dalJ.css +1 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/digests.d.ts +1 -0
- package/dist/digests.js +132 -42
- package/dist/doctor/checks.d.ts +0 -3
- package/dist/doctor/checks.js +1 -108
- package/dist/doctor/runner.js +1 -4
- package/dist/env-sanitizer.d.ts +2 -0
- package/dist/env-sanitizer.js +61 -0
- package/dist/exec-approval.d.ts +11 -1
- package/dist/exec-approval.js +17 -4
- package/dist/gateway.d.ts +3 -1
- package/dist/gateway.js +17 -7
- package/dist/heartbeat.js +1 -6
- package/dist/langfuse.js +3 -29
- package/dist/model-selection.js +3 -1
- package/dist/providers/adapter.d.ts +118 -0
- package/dist/providers/adapter.js +6 -0
- package/dist/providers/adapters/anthropic-adapter.d.ts +22 -0
- package/dist/providers/adapters/anthropic-adapter.js +204 -0
- package/dist/providers/adapters/codex-adapter.d.ts +26 -0
- package/dist/providers/adapters/codex-adapter.js +203 -0
- package/dist/providers/anthropic.d.ts +1 -0
- package/dist/providers/anthropic.js +10 -272
- package/dist/providers/codex.d.ts +21 -0
- package/dist/providers/codex.js +149 -330
- package/dist/providers/content.d.ts +1 -1
- package/dist/providers/content.js +2 -2
- package/dist/providers/context-manager.d.ts +18 -6
- package/dist/providers/context-manager.js +199 -223
- package/dist/providers/index.d.ts +9 -1
- package/dist/providers/index.js +73 -64
- package/dist/providers/loop-utils.d.ts +20 -0
- package/dist/providers/loop-utils.js +30 -0
- package/dist/providers/tool-loop.d.ts +12 -0
- package/dist/providers/tool-loop.js +251 -0
- package/dist/providers/utils.d.ts +19 -3
- package/dist/providers/utils.js +100 -29
- package/dist/secure-store.d.ts +8 -0
- package/dist/secure-store.js +80 -0
- package/dist/service.js +3 -28
- package/dist/sessions.d.ts +3 -0
- package/dist/sessions.js +147 -18
- package/dist/setup-templates.js +13 -25
- package/dist/setup.d.ts +10 -6
- package/dist/setup.js +84 -292
- package/dist/skills.js +3 -11
- package/dist/tools/agent-delegation.d.ts +19 -0
- package/dist/tools/agent-delegation.js +49 -0
- package/dist/tools/bash-tool.js +89 -34
- package/dist/tools/definitions.d.ts +199 -302
- package/dist/tools/definitions.js +70 -123
- package/dist/tools/execute-context.d.ts +13 -4
- package/dist/tools/fetch-tool.js +109 -13
- package/dist/tools/file-tools.js +7 -1
- package/dist/tools.d.ts +7 -7
- package/dist/tools.js +133 -151
- package/dist/types.d.ts +37 -30
- package/dist/utils.js +4 -6
- package/dist/voice.d.ts +1 -1
- package/dist/voice.js +17 -4
- package/package.json +33 -23
- package/templates/TOOLS.md +0 -27
- package/dist/__tests__/audit.test.js +0 -122
- package/dist/__tests__/code-agents-orchestrator.test.js +0 -216
- package/dist/__tests__/code-agents-sandbox.test.js +0 -163
- package/dist/__tests__/orchestrator.test.js +0 -425
- package/dist/__tests__/sandbox-bridge.test.js +0 -116
- package/dist/__tests__/sandbox-manager.test.js +0 -144
- package/dist/__tests__/sandbox-mount-security.test.js +0 -139
- package/dist/__tests__/sandbox-runtime.test.js +0 -176
- package/dist/__tests__/subagent.test.js +0 -240
- package/dist/__tests__/telegram.test.js +0 -42
- package/dist/code-agents/orchestrator.d.ts +0 -29
- package/dist/code-agents/orchestrator.js +0 -694
- package/dist/code-agents/worktree.d.ts +0 -40
- package/dist/code-agents/worktree.js +0 -215
- package/dist/dashboard/assets/index-BoTHPby4.js +0 -65
- package/dist/dashboard/assets/index-D4mufvBg.css +0 -1
- package/dist/dashboard.d.ts +0 -8
- package/dist/dashboard.js +0 -4071
- package/dist/discord.d.ts +0 -8
- package/dist/discord.js +0 -792
- package/dist/mcp-context-a8c.d.ts +0 -13
- package/dist/mcp-context-a8c.js +0 -34
- package/dist/orchestrator.d.ts +0 -15
- package/dist/orchestrator.js +0 -676
- package/dist/providers/openai.d.ts +0 -10
- package/dist/providers/openai.js +0 -355
- package/dist/sandbox/bridge.d.ts +0 -5
- package/dist/sandbox/bridge.js +0 -63
- package/dist/sandbox/index.d.ts +0 -5
- package/dist/sandbox/index.js +0 -4
- package/dist/sandbox/manager.d.ts +0 -7
- package/dist/sandbox/manager.js +0 -100
- package/dist/sandbox/mount-security.d.ts +0 -12
- package/dist/sandbox/mount-security.js +0 -122
- package/dist/sandbox/runtime.d.ts +0 -39
- package/dist/sandbox/runtime.js +0 -192
- package/dist/sandbox-utils.d.ts +0 -6
- package/dist/sandbox-utils.js +0 -36
- package/dist/subagent.d.ts +0 -19
- package/dist/subagent.js +0 -407
- package/dist/telegram.d.ts +0 -2
- package/dist/telegram.js +0 -11
- package/dist/tools/browser-tool.d.ts +0 -3
- package/dist/tools/browser-tool.js +0 -266
- package/sandbox/Dockerfile +0 -40
- /package/dist/__tests__/{audit.test.d.ts → code-agents-notifications.test.d.ts} +0 -0
- /package/dist/__tests__/{code-agents-orchestrator.test.d.ts → code-agents-worktrees.test.d.ts} +0 -0
- /package/dist/__tests__/{code-agents-sandbox.test.d.ts → codex-adapter.test.d.ts} +0 -0
- /package/dist/__tests__/{orchestrator.test.d.ts → codex-auth.test.d.ts} +0 -0
- /package/dist/__tests__/{sandbox-bridge.test.d.ts → codex-provider-gating.test.d.ts} +0 -0
- /package/dist/__tests__/{sandbox-manager.test.d.ts → codex-unified-loop.test.d.ts} +0 -0
- /package/dist/__tests__/{sandbox-mount-security.test.d.ts → config-security.test.d.ts} +0 -0
- /package/dist/__tests__/{sandbox-runtime.test.d.ts → cron-run.test.d.ts} +0 -0
- /package/dist/__tests__/{subagent.test.d.ts → digests.test.d.ts} +0 -0
- /package/dist/__tests__/{telegram.test.d.ts → discord-attachments.test.d.ts} +0 -0
package/dist/discord.js
DELETED
|
@@ -1,792 +0,0 @@
|
|
|
1
|
-
import { Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle, AttachmentBuilder, } from 'discord.js';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
import { homedir } from 'os';
|
|
4
|
-
import { tmpdir } from 'os';
|
|
5
|
-
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
|
|
6
|
-
import { getCurrentModel, setCurrentModel, getLastMessage } from './gateway.js';
|
|
7
|
-
import { getCronJobs, runCronJob } from './cron.js';
|
|
8
|
-
import { runAgentTurn } from './agent.js';
|
|
9
|
-
import { runHeartbeatCheck } from './heartbeat.js';
|
|
10
|
-
import { isAllowed, isRateLimited } from './security.js';
|
|
11
|
-
import { getActiveCodeAgents, getRecentCodeAgents } from './tools.js';
|
|
12
|
-
import { listApprovals, approveRequest, denyRequest, getApproval, onApprovalEvent, } from './exec-approval.js';
|
|
13
|
-
import { transcribeAudio, synthesizeSpeech } from './voice.js';
|
|
14
|
-
import * as sessions from './sessions.js';
|
|
15
|
-
import { formatAliases, formatModelSelectionError, getModelSelectionUsage, resolveModelSelection } from './model-selection.js';
|
|
16
|
-
function getDiscordRunContext(message) {
|
|
17
|
-
return {
|
|
18
|
-
userId: message.author.id,
|
|
19
|
-
sessionId: message.channel.id,
|
|
20
|
-
channel: 'discord',
|
|
21
|
-
trigger: 'discord',
|
|
22
|
-
metadata: {
|
|
23
|
-
username: message.author.username,
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
const BOT_COMMANDS = [
|
|
28
|
-
{ command: 'help', description: 'Show available commands' },
|
|
29
|
-
{ command: 'model', description: 'Switch model (fast/smart/opus)' },
|
|
30
|
-
{ command: 'status', description: 'Show bot status' },
|
|
31
|
-
{ command: 'clear', description: 'Clear conversation history' },
|
|
32
|
-
{ command: 'compact', description: 'Compress conversation history' },
|
|
33
|
-
{ command: 'silence', description: 'Pause proactive messages' },
|
|
34
|
-
{ command: 'cron', description: 'List or run scheduled jobs' },
|
|
35
|
-
{ command: 'tasks', description: 'List active coding agents and cron jobs' },
|
|
36
|
-
{ command: 'cancel', description: 'Cancel a coding agent (use dashboard) or cron job' },
|
|
37
|
-
{ command: 'approvals', description: 'List pending exec approvals' },
|
|
38
|
-
{ command: 'approve', description: 'Approve an exec request by ID' },
|
|
39
|
-
{ command: 'deny', description: 'Deny an exec request by ID' },
|
|
40
|
-
{ command: 'heartbeat', description: 'Trigger heartbeat check' },
|
|
41
|
-
];
|
|
42
|
-
const KNOWN_COMMANDS = new Set(BOT_COMMANDS.map(c => c.command));
|
|
43
|
-
const MAX_HISTORY_PAIRS = 5;
|
|
44
|
-
const chatHistory = new Map();
|
|
45
|
-
// Track which keys have been loaded from disk this session
|
|
46
|
-
const loadedFromDisk = new Set();
|
|
47
|
-
const DEFAULT_DISCORD_TOOLS = {
|
|
48
|
-
enabled: true,
|
|
49
|
-
allowedPaths: [join(homedir(), '.skimpyclaw')],
|
|
50
|
-
maxIterations: 30,
|
|
51
|
-
bashTimeout: 15000,
|
|
52
|
-
};
|
|
53
|
-
let client = null;
|
|
54
|
-
let config;
|
|
55
|
-
let silenceUntil = null;
|
|
56
|
-
async function getHistory(key) {
|
|
57
|
-
// Lazy-load from disk on first access this session
|
|
58
|
-
if (!loadedFromDisk.has(key)) {
|
|
59
|
-
loadedFromDisk.add(key);
|
|
60
|
-
const diskHistory = await sessions.loadHistory('discord', key).catch(() => []);
|
|
61
|
-
if (diskHistory.length > 0 && !chatHistory.has(key)) {
|
|
62
|
-
chatHistory.set(key, diskHistory);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return chatHistory.get(key) || [];
|
|
66
|
-
}
|
|
67
|
-
async function addToHistory(key, userMsg, assistantMsg) {
|
|
68
|
-
const history = await getHistory(key);
|
|
69
|
-
history.push({ role: 'user', content: userMsg });
|
|
70
|
-
history.push({ role: 'assistant', content: assistantMsg });
|
|
71
|
-
while (history.length > MAX_HISTORY_PAIRS * 2) {
|
|
72
|
-
history.shift();
|
|
73
|
-
history.shift();
|
|
74
|
-
}
|
|
75
|
-
chatHistory.set(key, history);
|
|
76
|
-
// Persist to disk (fire-and-forget)
|
|
77
|
-
sessions.saveExchange('discord', key, userMsg, assistantMsg).catch(() => { });
|
|
78
|
-
}
|
|
79
|
-
async function clearHistory(key) {
|
|
80
|
-
chatHistory.delete(key);
|
|
81
|
-
loadedFromDisk.delete(key);
|
|
82
|
-
await sessions.clearHistory('discord', key).catch(() => { });
|
|
83
|
-
}
|
|
84
|
-
function getDiscordToolConfig(cfg) {
|
|
85
|
-
const discord = cfg.channels.discord;
|
|
86
|
-
if (discord?.tools) {
|
|
87
|
-
return {
|
|
88
|
-
...DEFAULT_DISCORD_TOOLS,
|
|
89
|
-
...discord.tools,
|
|
90
|
-
allowedPaths: discord.tools.allowedPaths ?? discord.defaultAllowedPaths ?? DEFAULT_DISCORD_TOOLS.allowedPaths,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
if (discord?.defaultAllowedPaths?.length) {
|
|
94
|
-
return {
|
|
95
|
-
...DEFAULT_DISCORD_TOOLS,
|
|
96
|
-
allowedPaths: discord.defaultAllowedPaths,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
return DEFAULT_DISCORD_TOOLS;
|
|
100
|
-
}
|
|
101
|
-
function conversationKey(message) {
|
|
102
|
-
if (message.channel.isDMBased()) {
|
|
103
|
-
return `dm:${message.author.id}`;
|
|
104
|
-
}
|
|
105
|
-
return `channel:${message.channelId}`;
|
|
106
|
-
}
|
|
107
|
-
function buildHelpText(cfg) {
|
|
108
|
-
const agentConfig = cfg.agents.list[cfg.agents.default];
|
|
109
|
-
const emoji = agentConfig?.identity?.emoji || '🦞';
|
|
110
|
-
const name = agentConfig?.identity?.name || 'SkimpyClaw';
|
|
111
|
-
const commandList = BOT_COMMANDS.map(c => `/${c.command} - ${c.description}`).join('\n');
|
|
112
|
-
return `${emoji} ${name} online.\n\nSend a message to chat, or use a command:\n\n${commandList}`;
|
|
113
|
-
}
|
|
114
|
-
function splitToChunks(text, maxLength) {
|
|
115
|
-
if (text.length <= maxLength)
|
|
116
|
-
return [text];
|
|
117
|
-
const chunks = [];
|
|
118
|
-
let current = '';
|
|
119
|
-
for (const paragraph of text.split('\n\n')) {
|
|
120
|
-
if (current.length + paragraph.length + 2 > maxLength) {
|
|
121
|
-
if (current)
|
|
122
|
-
chunks.push(current.trim());
|
|
123
|
-
// If a single paragraph exceeds maxLength, split it on newlines or hard-cut
|
|
124
|
-
if (paragraph.length > maxLength) {
|
|
125
|
-
const lines = paragraph.split('\n');
|
|
126
|
-
let lineBuf = '';
|
|
127
|
-
for (const line of lines) {
|
|
128
|
-
if (lineBuf.length + line.length + 1 > maxLength) {
|
|
129
|
-
if (lineBuf)
|
|
130
|
-
chunks.push(lineBuf.trim());
|
|
131
|
-
// If a single line still exceeds, hard-cut it
|
|
132
|
-
if (line.length > maxLength) {
|
|
133
|
-
for (let i = 0; i < line.length; i += maxLength) {
|
|
134
|
-
chunks.push(line.slice(i, i + maxLength));
|
|
135
|
-
}
|
|
136
|
-
lineBuf = '';
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
lineBuf = line;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
else {
|
|
143
|
-
lineBuf += (lineBuf ? '\n' : '') + line;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
current = lineBuf;
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
current = paragraph;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
current += (current ? '\n\n' : '') + paragraph;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (current)
|
|
157
|
-
chunks.push(current.trim());
|
|
158
|
-
return chunks.filter(c => c.length > 0);
|
|
159
|
-
}
|
|
160
|
-
async function sendLongText(message, text) {
|
|
161
|
-
const chunks = splitToChunks(text, 1900);
|
|
162
|
-
for (const chunk of chunks) {
|
|
163
|
-
await message.reply(chunk);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
function startTypingIndicator(message) {
|
|
167
|
-
const maxDurationMs = 90_000;
|
|
168
|
-
let stopped = false;
|
|
169
|
-
const stop = () => {
|
|
170
|
-
if (stopped)
|
|
171
|
-
return;
|
|
172
|
-
stopped = true;
|
|
173
|
-
clearInterval(interval);
|
|
174
|
-
clearTimeout(watchdog);
|
|
175
|
-
};
|
|
176
|
-
const channel = message.channel;
|
|
177
|
-
if (typeof channel.sendTyping === 'function') {
|
|
178
|
-
void channel.sendTyping().catch(() => { });
|
|
179
|
-
}
|
|
180
|
-
const interval = setInterval(() => {
|
|
181
|
-
if (stopped)
|
|
182
|
-
return;
|
|
183
|
-
if (typeof channel.sendTyping === 'function') {
|
|
184
|
-
void channel.sendTyping().catch(() => { });
|
|
185
|
-
}
|
|
186
|
-
}, 4000);
|
|
187
|
-
const watchdog = setTimeout(() => {
|
|
188
|
-
console.warn('[discord] Typing indicator watchdog reached; auto-stopping.');
|
|
189
|
-
stop();
|
|
190
|
-
}, maxDurationMs);
|
|
191
|
-
return stop;
|
|
192
|
-
}
|
|
193
|
-
async function handleCommand(message, command, args) {
|
|
194
|
-
const rawArgs = args.join(' ').trim();
|
|
195
|
-
if (command === 'start' || command === 'help') {
|
|
196
|
-
await sendLongText(message, buildHelpText(config));
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
if (command === 'model') {
|
|
200
|
-
if (!rawArgs) {
|
|
201
|
-
const current = getCurrentModel();
|
|
202
|
-
const aliases = formatAliases(config);
|
|
203
|
-
await message.reply(`Current: ${current}\nAliases: ${aliases}\n\nUsage: /model <alias|provider/model|model-id>\n${getModelSelectionUsage()}`);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
const selection = resolveModelSelection(rawArgs, config);
|
|
207
|
-
if (!selection.ok || !selection.resolved) {
|
|
208
|
-
const errorMessage = selection.error || 'Invalid model selection';
|
|
209
|
-
await message.reply(formatModelSelectionError(errorMessage, config));
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
setCurrentModel(selection.resolved);
|
|
213
|
-
if (selection.aliasUsed) {
|
|
214
|
-
await message.reply(`Model switched to: ${selection.aliasUsed} (${selection.resolved})`);
|
|
215
|
-
}
|
|
216
|
-
else {
|
|
217
|
-
await message.reply(`Model switched to: ${selection.resolved}`);
|
|
218
|
-
}
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
if (command === 'status') {
|
|
222
|
-
const model = getCurrentModel();
|
|
223
|
-
const last = getLastMessage();
|
|
224
|
-
const jobs = getCronJobs();
|
|
225
|
-
const jobList = jobs.map(j => `- ${j.name}: ${j.nextRun?.toLocaleString() || 'unknown'}`).join('\n');
|
|
226
|
-
// Coding agents status (multi-agent)
|
|
227
|
-
const caActive = getActiveCodeAgents();
|
|
228
|
-
const caRecent = getRecentCodeAgents(3);
|
|
229
|
-
const caAll = [...caActive, ...caRecent];
|
|
230
|
-
let caLine = 'Coding Agents: idle';
|
|
231
|
-
if (caAll.length > 0) {
|
|
232
|
-
const runningCount = caActive.length;
|
|
233
|
-
const completedCount = caRecent.filter(t => t.status === 'completed').length;
|
|
234
|
-
const failedCount = caRecent.filter(t => t.status === 'failed' || t.status === 'timeout').length;
|
|
235
|
-
const parts = [];
|
|
236
|
-
if (runningCount)
|
|
237
|
-
parts.push(`${runningCount} running`);
|
|
238
|
-
if (completedCount)
|
|
239
|
-
parts.push(`${completedCount} completed`);
|
|
240
|
-
if (failedCount)
|
|
241
|
-
parts.push(`${failedCount} failed`);
|
|
242
|
-
caLine = `Coding Agents: ${parts.join(', ') || 'idle'}`;
|
|
243
|
-
const caPreview = caAll.slice(0, 5).map(t => {
|
|
244
|
-
const elapsed = t.durationSeconds != null
|
|
245
|
-
? (t.durationSeconds < 60 ? `${t.durationSeconds}s` : `${Math.floor(t.durationSeconds / 60)}m ${t.durationSeconds % 60}s`)
|
|
246
|
-
: (Math.round((Date.now() - new Date(t.startedAt).getTime()) / 1000) + 's');
|
|
247
|
-
const taskPreview = t.task.length > 50 ? t.task.slice(0, 50) + '...' : t.task;
|
|
248
|
-
return ` ${t.id}: ${t.status.toUpperCase()} (${t.agent}, ${elapsed}) — ${taskPreview}`;
|
|
249
|
-
}).join('\n');
|
|
250
|
-
if (caPreview)
|
|
251
|
-
caLine += '\n' + caPreview;
|
|
252
|
-
}
|
|
253
|
-
await message.reply(`Agent: ${config.agents.default}\n` +
|
|
254
|
-
`Model: ${model}\n` +
|
|
255
|
-
`Last message: ${last?.toLocaleString() || 'never'}\n` +
|
|
256
|
-
`Silence until: ${silenceUntil?.toLocaleString() || 'not silenced'}\n\n` +
|
|
257
|
-
`${caLine}\n\n` +
|
|
258
|
-
`Scheduled jobs:\n${jobList || '(none)'}`);
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
261
|
-
if (command === 'cron') {
|
|
262
|
-
const subcommand = args[0];
|
|
263
|
-
if (!subcommand || subcommand === 'list') {
|
|
264
|
-
const jobs = getCronJobs();
|
|
265
|
-
if (jobs.length === 0) {
|
|
266
|
-
await message.reply('No scheduled jobs.');
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
const list = jobs.map(j => `${j.id}: ${j.name} (next: ${j.nextRun?.toLocaleString() || '?'})`).join('\n');
|
|
270
|
-
await message.reply(`Scheduled jobs:\n${list}`);
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
if (subcommand === 'run') {
|
|
274
|
-
const jobId = args[1];
|
|
275
|
-
if (!jobId) {
|
|
276
|
-
await message.reply('Usage: /cron run <job-id>');
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
try {
|
|
280
|
-
await runCronJob(jobId, config);
|
|
281
|
-
await message.reply(`Triggered: ${jobId}`);
|
|
282
|
-
}
|
|
283
|
-
catch (error) {
|
|
284
|
-
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
285
|
-
await message.reply(`Error: ${msg}`);
|
|
286
|
-
}
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
await message.reply('Usage: /cron list | /cron run <id>');
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
if (command === 'heartbeat') {
|
|
293
|
-
const stopTyping = startTypingIndicator(message);
|
|
294
|
-
try {
|
|
295
|
-
const response = await runHeartbeatCheck(config);
|
|
296
|
-
await sendLongText(message, `Heartbeat:\n\n${response}`);
|
|
297
|
-
}
|
|
298
|
-
catch (error) {
|
|
299
|
-
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
300
|
-
await message.reply(`Heartbeat error: ${msg}`);
|
|
301
|
-
}
|
|
302
|
-
finally {
|
|
303
|
-
stopTyping();
|
|
304
|
-
}
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
if (command === 'approvals') {
|
|
308
|
-
const pending = listApprovals();
|
|
309
|
-
if (pending.length === 0) {
|
|
310
|
-
await message.reply('No pending exec approvals.');
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
for (const approval of pending.slice(0, 10)) {
|
|
314
|
-
const cmdPreview = approval.command.length > 80
|
|
315
|
-
? approval.command.slice(0, 80) + '...'
|
|
316
|
-
: approval.command;
|
|
317
|
-
const expiresIn = Math.max(0, Math.round((approval.expiresAt.getTime() - Date.now()) / 1000));
|
|
318
|
-
const expiresStr = expiresIn < 60 ? `${expiresIn}s` : `${Math.floor(expiresIn / 60)}m`;
|
|
319
|
-
const row = new ActionRowBuilder().addComponents(new ButtonBuilder()
|
|
320
|
-
.setCustomId(`approve:${approval.id}`)
|
|
321
|
-
.setLabel('Approve')
|
|
322
|
-
.setStyle(ButtonStyle.Success), new ButtonBuilder()
|
|
323
|
-
.setCustomId(`deny:${approval.id}`)
|
|
324
|
-
.setLabel('Deny')
|
|
325
|
-
.setStyle(ButtonStyle.Danger));
|
|
326
|
-
await message.reply({
|
|
327
|
-
content: `⛔ Approval #${approval.id}\n` +
|
|
328
|
-
`Tier ${approval.tier}: ${approval.reason}\n` +
|
|
329
|
-
`Command: ${cmdPreview}\n` +
|
|
330
|
-
`${approval.cwd ? `CWD: ${approval.cwd}\n` : ''}` +
|
|
331
|
-
`Expires in: ${expiresStr}`,
|
|
332
|
-
components: [row],
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
if (command === 'approve') {
|
|
338
|
-
const id = rawArgs;
|
|
339
|
-
if (!id) {
|
|
340
|
-
await message.reply('Usage: /approve <id>');
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
const by = message.author.username || message.author.id;
|
|
344
|
-
const success = approveRequest(id, by);
|
|
345
|
-
if (success) {
|
|
346
|
-
await message.reply(`✅ Approved #${id}`);
|
|
347
|
-
}
|
|
348
|
-
else {
|
|
349
|
-
const existing = getApproval(id);
|
|
350
|
-
if (existing) {
|
|
351
|
-
await message.reply(`Cannot approve #${id} — status is already "${existing.status}".`);
|
|
352
|
-
}
|
|
353
|
-
else {
|
|
354
|
-
await message.reply(`No pending approval found with ID "${id}".`);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
if (command === 'deny') {
|
|
360
|
-
const id = rawArgs;
|
|
361
|
-
if (!id) {
|
|
362
|
-
await message.reply('Usage: /deny <id>');
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
const by = message.author.username || message.author.id;
|
|
366
|
-
const success = denyRequest(id, by);
|
|
367
|
-
if (success) {
|
|
368
|
-
await message.reply(`❌ Denied #${id}`);
|
|
369
|
-
}
|
|
370
|
-
else {
|
|
371
|
-
const existing = getApproval(id);
|
|
372
|
-
if (existing) {
|
|
373
|
-
await message.reply(`Cannot deny #${id} — status is already "${existing.status}".`);
|
|
374
|
-
}
|
|
375
|
-
else {
|
|
376
|
-
await message.reply(`No pending approval found with ID "${id}".`);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
if (command === 'tasks') {
|
|
382
|
-
await message.reply('Use `/agents` to list active coding agents, or `/cron` to manage scheduled tasks.');
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
if (command === 'cancel') {
|
|
386
|
-
await message.reply('Use the dashboard to cancel coding agents, or `/cron` to manage scheduled tasks.');
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
if (command === 'clear') {
|
|
390
|
-
await clearHistory(conversationKey(message));
|
|
391
|
-
await message.reply('Conversation cleared. Starting fresh.');
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
if (command === 'compact') {
|
|
395
|
-
const key = conversationKey(message);
|
|
396
|
-
const history = await getHistory(key);
|
|
397
|
-
if (history.length === 0) {
|
|
398
|
-
await message.reply('No conversation history to compact.');
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
const stopTyping = startTypingIndicator(message);
|
|
402
|
-
try {
|
|
403
|
-
const historyText = history.map(m => `${m.role}: ${m.content}`).join('\n');
|
|
404
|
-
const summary = await runAgentTurn(config.agents.default, `Summarize this conversation in 2-3 sentences so you can remember the context:\n\n${historyText}`, config, getCurrentModel(), undefined, undefined, getDiscordRunContext(message));
|
|
405
|
-
await clearHistory(key);
|
|
406
|
-
chatHistory.set(key, [
|
|
407
|
-
{ role: 'user', content: 'Summary of our previous conversation:' },
|
|
408
|
-
{ role: 'assistant', content: summary },
|
|
409
|
-
]);
|
|
410
|
-
loadedFromDisk.add(key); // Mark as loaded so we don't re-load on next access
|
|
411
|
-
await sessions.replaceWithSummary('discord', key, summary);
|
|
412
|
-
await message.reply(`Compacted ${history.length} messages into a summary.`);
|
|
413
|
-
}
|
|
414
|
-
catch (error) {
|
|
415
|
-
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
416
|
-
await message.reply(`Error: ${msg}`);
|
|
417
|
-
}
|
|
418
|
-
finally {
|
|
419
|
-
stopTyping();
|
|
420
|
-
}
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
if (command === 'silence') {
|
|
424
|
-
const minutes = parseInt(rawArgs, 10) || 30;
|
|
425
|
-
silenceUntil = new Date(Date.now() + minutes * 60 * 1000);
|
|
426
|
-
await message.reply(`Proactive messages silenced until ${silenceUntil.toLocaleTimeString()}`);
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
await message.reply(`Unknown command: /${command}\n\nType /help to see available commands.`);
|
|
430
|
-
}
|
|
431
|
-
async function handleIncomingMessage(message) {
|
|
432
|
-
if (message.author.bot)
|
|
433
|
-
return;
|
|
434
|
-
if (!config.channels.discord)
|
|
435
|
-
return;
|
|
436
|
-
console.log(`[discord] Received message from ${message.author.id} in ${message.channelId}: ${JSON.stringify(message.content).slice(0, 120)}`);
|
|
437
|
-
const senderId = message.author.id;
|
|
438
|
-
const senderUsername = message.author.username;
|
|
439
|
-
if (!isAllowed(config.channels.discord.allowFrom, senderId, senderUsername)) {
|
|
440
|
-
console.log(`[discord] Blocked message from ${senderId} (@${senderUsername})`);
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
if (isRateLimited(senderId)) {
|
|
444
|
-
await message.reply('Too many messages. Please wait a moment.');
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
// Check for image attachments
|
|
448
|
-
const imageAttachments = message.attachments.filter(a => a.contentType?.startsWith('image/'));
|
|
449
|
-
if (imageAttachments.size > 0) {
|
|
450
|
-
const attachment = imageAttachments.first();
|
|
451
|
-
const stopTyping = startTypingIndicator(message);
|
|
452
|
-
try {
|
|
453
|
-
// Download the image
|
|
454
|
-
const imageResponse = await fetch(attachment.url);
|
|
455
|
-
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
|
456
|
-
const base64Image = imageBuffer.toString('base64');
|
|
457
|
-
// Determine media type
|
|
458
|
-
const mediaType = attachment.contentType || 'image/jpeg';
|
|
459
|
-
// Get message text or use default
|
|
460
|
-
const caption = message.content.trim() || "What's in this image?";
|
|
461
|
-
// Build multi-part content array
|
|
462
|
-
const content = [
|
|
463
|
-
{
|
|
464
|
-
type: 'image',
|
|
465
|
-
source: {
|
|
466
|
-
type: 'base64',
|
|
467
|
-
media_type: mediaType,
|
|
468
|
-
data: base64Image,
|
|
469
|
-
},
|
|
470
|
-
},
|
|
471
|
-
{
|
|
472
|
-
type: 'text',
|
|
473
|
-
text: caption,
|
|
474
|
-
},
|
|
475
|
-
];
|
|
476
|
-
const key = conversationKey(message);
|
|
477
|
-
const history = await getHistory(key);
|
|
478
|
-
const response = await runAgentTurn(config.agents.default, content, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
|
|
479
|
-
await addToHistory(key, `[Image: ${caption}]`, response);
|
|
480
|
-
await sendLongText(message, response);
|
|
481
|
-
}
|
|
482
|
-
catch (error) {
|
|
483
|
-
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
484
|
-
await message.reply(`Error processing image: ${msg}`);
|
|
485
|
-
}
|
|
486
|
-
finally {
|
|
487
|
-
stopTyping();
|
|
488
|
-
}
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
// Check for voice message attachments
|
|
492
|
-
const voiceAttachments = message.attachments.filter(a => a.contentType?.startsWith('audio/') || a.contentType?.startsWith('voice/'));
|
|
493
|
-
if (voiceAttachments.size > 0) {
|
|
494
|
-
const attachment = voiceAttachments.first();
|
|
495
|
-
const stopTyping = startTypingIndicator(message);
|
|
496
|
-
try {
|
|
497
|
-
// Check if voice config is available
|
|
498
|
-
if (!config.voice) {
|
|
499
|
-
await message.reply('Voice transcription not configured. Add a "voice" section to config.json.');
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
// Download the voice file
|
|
503
|
-
const voiceResponse = await fetch(attachment.url);
|
|
504
|
-
const buffer = Buffer.from(await voiceResponse.arrayBuffer());
|
|
505
|
-
// Save to temp file
|
|
506
|
-
const ext = attachment.name?.split('.').pop() || 'ogg';
|
|
507
|
-
const tempDir = join(tmpdir(), 'skimpyclaw-voice');
|
|
508
|
-
if (!existsSync(tempDir))
|
|
509
|
-
mkdirSync(tempDir, { recursive: true });
|
|
510
|
-
const tempPath = join(tempDir, `discord-voice-${Date.now()}.${ext}`);
|
|
511
|
-
writeFileSync(tempPath, buffer);
|
|
512
|
-
try {
|
|
513
|
-
// Transcribe
|
|
514
|
-
const result = await transcribeAudio(tempPath, config.voice);
|
|
515
|
-
const transcription = result.text.trim();
|
|
516
|
-
console.log(`[discord] Transcription result: ${transcription}`);
|
|
517
|
-
if (!transcription) {
|
|
518
|
-
await message.reply('Could not transcribe audio — no speech detected.');
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
const key = conversationKey(message);
|
|
522
|
-
const history = await getHistory(key);
|
|
523
|
-
const agentResponse = await runAgentTurn(config.agents.default, transcription, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
|
|
524
|
-
await addToHistory(key, transcription, agentResponse);
|
|
525
|
-
// TTS voice reply if sendVoice enabled
|
|
526
|
-
console.log('[discord] TTS check - sendVoice:', config.voice?.channels?.['discord']?.sendVoice);
|
|
527
|
-
if (config.voice?.channels?.['discord']?.sendVoice) {
|
|
528
|
-
console.log('[discord] Attempting TTS synthesis...');
|
|
529
|
-
try {
|
|
530
|
-
const speech = await synthesizeSpeech(agentResponse, config.voice);
|
|
531
|
-
console.log('[discord] TTS synthesis success:', speech.format, speech.provider, 'buffer size:', speech.buffer.length);
|
|
532
|
-
const attachment = new AttachmentBuilder(speech.buffer, {
|
|
533
|
-
name: `voice-reply.${speech.format}`,
|
|
534
|
-
description: 'Voice reply'
|
|
535
|
-
});
|
|
536
|
-
await message.reply({ files: [attachment] });
|
|
537
|
-
console.log('[discord] Voice reply sent');
|
|
538
|
-
}
|
|
539
|
-
catch (err) {
|
|
540
|
-
console.error('[discord] TTS synthesis failed:', err);
|
|
541
|
-
// Non-fatal — text reply still sends below
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
// Format response with transcription in a blockquote
|
|
545
|
-
const combined = `> 🎤 ${transcription}\n\n${agentResponse}`;
|
|
546
|
-
await sendLongText(message, combined);
|
|
547
|
-
}
|
|
548
|
-
finally {
|
|
549
|
-
// Clean up temp file
|
|
550
|
-
try {
|
|
551
|
-
unlinkSync(tempPath);
|
|
552
|
-
}
|
|
553
|
-
catch { /* best effort */ }
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
catch (error) {
|
|
557
|
-
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
558
|
-
await message.reply(`Voice transcription error: ${msg}`);
|
|
559
|
-
}
|
|
560
|
-
finally {
|
|
561
|
-
stopTyping();
|
|
562
|
-
}
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
const text = message.content.trim();
|
|
566
|
-
if (!text)
|
|
567
|
-
return;
|
|
568
|
-
const isPrefixedCommand = text.startsWith('/') || text.startsWith('!');
|
|
569
|
-
const isDm = message.channel.isDMBased();
|
|
570
|
-
if (isPrefixedCommand || isDm) {
|
|
571
|
-
const commandText = isPrefixedCommand ? text.slice(1).trim() : text;
|
|
572
|
-
const [commandPart, ...args] = commandText.split(/\s+/);
|
|
573
|
-
const command = (commandPart || '').toLowerCase();
|
|
574
|
-
if (!KNOWN_COMMANDS.has(command)) {
|
|
575
|
-
if (isPrefixedCommand) {
|
|
576
|
-
await message.reply(`Unknown command: /${command}\n\nType /help to see available commands.`);
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
else {
|
|
581
|
-
await handleCommand(message, command, args);
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
const key = conversationKey(message);
|
|
586
|
-
const stopTyping = startTypingIndicator(message);
|
|
587
|
-
try {
|
|
588
|
-
const history = await getHistory(key);
|
|
589
|
-
const response = await runAgentTurn(config.agents.default, text, config, getCurrentModel(), getDiscordToolConfig(config), history, getDiscordRunContext(message));
|
|
590
|
-
await addToHistory(key, text, response);
|
|
591
|
-
await sendLongText(message, response);
|
|
592
|
-
}
|
|
593
|
-
catch (error) {
|
|
594
|
-
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
595
|
-
await message.reply(`Error: ${msg}`);
|
|
596
|
-
}
|
|
597
|
-
finally {
|
|
598
|
-
stopTyping();
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
/** Send an approval card message with approve/deny buttons to a Discord channel. */
|
|
602
|
-
async function sendApprovalCard(channelId, approval) {
|
|
603
|
-
if (!client)
|
|
604
|
-
return;
|
|
605
|
-
const channel = await client.channels.fetch(channelId).catch(() => null);
|
|
606
|
-
if (!channel || !('send' in channel) || typeof channel.send !== 'function')
|
|
607
|
-
return;
|
|
608
|
-
const cmdPreview = approval.command.length > 80
|
|
609
|
-
? approval.command.slice(0, 80) + '...'
|
|
610
|
-
: approval.command;
|
|
611
|
-
const expiresIn = Math.max(0, Math.round((approval.expiresAt.getTime() - Date.now()) / 1000));
|
|
612
|
-
const expiresStr = expiresIn < 60 ? `${expiresIn}s` : `${Math.floor(expiresIn / 60)}m`;
|
|
613
|
-
const row = new ActionRowBuilder().addComponents(new ButtonBuilder()
|
|
614
|
-
.setCustomId(`approve:${approval.id}`)
|
|
615
|
-
.setLabel('Approve')
|
|
616
|
-
.setStyle(ButtonStyle.Success), new ButtonBuilder()
|
|
617
|
-
.setCustomId(`deny:${approval.id}`)
|
|
618
|
-
.setLabel('Deny')
|
|
619
|
-
.setStyle(ButtonStyle.Danger));
|
|
620
|
-
await channel.send({
|
|
621
|
-
content: `⛔ Exec approval needed: #${approval.id}\n` +
|
|
622
|
-
`Tier ${approval.tier}: ${approval.reason}\n` +
|
|
623
|
-
`Command: ${cmdPreview}\n` +
|
|
624
|
-
`${approval.cwd ? `CWD: ${approval.cwd}\n` : ''}` +
|
|
625
|
-
`Expires in: ${expiresStr}`,
|
|
626
|
-
components: [row],
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
/** Handle Discord button interactions for approval approve/deny. */
|
|
630
|
-
async function handleInteraction(interaction) {
|
|
631
|
-
if (!interaction.isButton())
|
|
632
|
-
return;
|
|
633
|
-
const customId = interaction.customId;
|
|
634
|
-
const [action, id] = customId.split(':');
|
|
635
|
-
if (!id || (action !== 'approve' && action !== 'deny')) {
|
|
636
|
-
await interaction.reply({ content: 'Unknown action', ephemeral: true });
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
const by = interaction.user.username || interaction.user.id;
|
|
640
|
-
let success;
|
|
641
|
-
let statusText;
|
|
642
|
-
if (action === 'approve') {
|
|
643
|
-
success = approveRequest(id, by);
|
|
644
|
-
statusText = success ? `✅ Approved by @${by}` : 'Failed — not pending';
|
|
645
|
-
}
|
|
646
|
-
else {
|
|
647
|
-
success = denyRequest(id, by);
|
|
648
|
-
statusText = success ? `❌ Denied by @${by}` : 'Failed — not pending';
|
|
649
|
-
}
|
|
650
|
-
// Ephemeral acknowledgement to the clicker
|
|
651
|
-
await interaction.reply({ content: statusText, ephemeral: true });
|
|
652
|
-
// Update the original message to reflect the resolved status
|
|
653
|
-
try {
|
|
654
|
-
const approval = getApproval(id);
|
|
655
|
-
if (approval) {
|
|
656
|
-
const cmdPreview = approval.command.length > 80
|
|
657
|
-
? approval.command.slice(0, 80) + '...'
|
|
658
|
-
: approval.command;
|
|
659
|
-
await interaction.message.edit({
|
|
660
|
-
content: `${statusText}\n\n` +
|
|
661
|
-
`Approval #${id}\n` +
|
|
662
|
-
`Tier ${approval.tier}: ${approval.reason}\n` +
|
|
663
|
-
`Command: ${cmdPreview}`,
|
|
664
|
-
components: [], // Remove buttons after resolution
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
catch {
|
|
669
|
-
// Message may already be edited or deleted — ignore
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
export async function initDiscord(cfg) {
|
|
673
|
-
const discord = cfg.channels.discord;
|
|
674
|
-
if (!discord?.enabled || !discord.token) {
|
|
675
|
-
console.log('[discord] Disabled or no token configured');
|
|
676
|
-
return false;
|
|
677
|
-
}
|
|
678
|
-
config = cfg;
|
|
679
|
-
client = new Client({
|
|
680
|
-
intents: [
|
|
681
|
-
GatewayIntentBits.Guilds,
|
|
682
|
-
GatewayIntentBits.GuildMessages,
|
|
683
|
-
GatewayIntentBits.DirectMessages,
|
|
684
|
-
GatewayIntentBits.MessageContent,
|
|
685
|
-
],
|
|
686
|
-
partials: [Partials.Channel],
|
|
687
|
-
});
|
|
688
|
-
client.on('messageCreate', (message) => {
|
|
689
|
-
void handleIncomingMessage(message);
|
|
690
|
-
});
|
|
691
|
-
// Handle button interactions (approval approve/deny)
|
|
692
|
-
client.on('interactionCreate', (interaction) => {
|
|
693
|
-
void handleInteraction(interaction);
|
|
694
|
-
});
|
|
695
|
-
client.once('clientReady', () => {
|
|
696
|
-
console.log(`[discord] Bot started as ${client?.user?.tag ?? 'unknown'}`);
|
|
697
|
-
});
|
|
698
|
-
client.on('error', (error) => {
|
|
699
|
-
console.error('[discord] Client error:', error);
|
|
700
|
-
});
|
|
701
|
-
// Subscribe to approval-created events — proactively post to Discord for discord-origin approvals
|
|
702
|
-
onApprovalEvent('created', (event) => {
|
|
703
|
-
if (!client)
|
|
704
|
-
return;
|
|
705
|
-
const { approval } = event;
|
|
706
|
-
const meta = approval.channelMeta;
|
|
707
|
-
// Only post discord-origin approvals (or fallback when no channel set)
|
|
708
|
-
if (meta?.channel && meta.channel !== 'discord')
|
|
709
|
-
return;
|
|
710
|
-
let targetChannelId;
|
|
711
|
-
if (meta?.chatId) {
|
|
712
|
-
targetChannelId = String(meta.chatId);
|
|
713
|
-
}
|
|
714
|
-
if (!targetChannelId) {
|
|
715
|
-
targetChannelId = getDiscordDefaultTarget(cfg) ?? undefined;
|
|
716
|
-
}
|
|
717
|
-
if (!targetChannelId)
|
|
718
|
-
return;
|
|
719
|
-
void sendApprovalCard(targetChannelId, approval).catch((err) => {
|
|
720
|
-
console.error('[discord] Failed to send approval notification:', err);
|
|
721
|
-
});
|
|
722
|
-
});
|
|
723
|
-
return true;
|
|
724
|
-
}
|
|
725
|
-
export async function startDiscord() {
|
|
726
|
-
if (!client || !config.channels.discord?.token)
|
|
727
|
-
return;
|
|
728
|
-
console.log('[discord] Starting bot...');
|
|
729
|
-
await client.login(config.channels.discord.token);
|
|
730
|
-
}
|
|
731
|
-
export async function stopDiscord() {
|
|
732
|
-
if (!client)
|
|
733
|
-
return;
|
|
734
|
-
client.destroy();
|
|
735
|
-
console.log('[discord] Bot stopped');
|
|
736
|
-
}
|
|
737
|
-
export function isDiscordSilenced() {
|
|
738
|
-
if (!silenceUntil)
|
|
739
|
-
return false;
|
|
740
|
-
return new Date() < silenceUntil;
|
|
741
|
-
}
|
|
742
|
-
export function getDiscordDefaultTarget(cfg) {
|
|
743
|
-
const discord = cfg.channels.discord;
|
|
744
|
-
if (!discord)
|
|
745
|
-
return null;
|
|
746
|
-
if (discord.defaultChannelId?.trim())
|
|
747
|
-
return discord.defaultChannelId.trim();
|
|
748
|
-
for (const entry of discord.allowFrom) {
|
|
749
|
-
const value = String(entry).trim();
|
|
750
|
-
if (value)
|
|
751
|
-
return value;
|
|
752
|
-
}
|
|
753
|
-
return null;
|
|
754
|
-
}
|
|
755
|
-
async function sendChunked(target, text) {
|
|
756
|
-
const chunks = splitToChunks(text, 1900);
|
|
757
|
-
for (const chunk of chunks) {
|
|
758
|
-
await target.send(chunk);
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
export async function sendDiscordProactiveMessage(target, message) {
|
|
762
|
-
if (!client || isDiscordSilenced())
|
|
763
|
-
return;
|
|
764
|
-
const targetId = String(target);
|
|
765
|
-
const channel = await client.channels.fetch(targetId).catch(() => null);
|
|
766
|
-
if (channel && 'send' in channel && typeof channel.send === 'function') {
|
|
767
|
-
await sendChunked(channel, message);
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
const user = await client.users.fetch(targetId).catch(() => null);
|
|
771
|
-
if (user) {
|
|
772
|
-
await sendChunked(user, message);
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
export async function sendDiscordProactiveVoice(target, buffer, format) {
|
|
776
|
-
if (!client || isDiscordSilenced())
|
|
777
|
-
return;
|
|
778
|
-
const targetId = String(target);
|
|
779
|
-
const attachment = new AttachmentBuilder(buffer, {
|
|
780
|
-
name: `voice.${format}`,
|
|
781
|
-
description: 'Voice message',
|
|
782
|
-
});
|
|
783
|
-
const channel = await client.channels.fetch(targetId).catch(() => null);
|
|
784
|
-
if (channel && 'send' in channel && typeof channel.send === 'function') {
|
|
785
|
-
await channel.send({ files: [attachment] });
|
|
786
|
-
return;
|
|
787
|
-
}
|
|
788
|
-
const user = await client.users.fetch(targetId).catch(() => null);
|
|
789
|
-
if (user) {
|
|
790
|
-
await user.send({ files: [attachment] });
|
|
791
|
-
}
|
|
792
|
-
}
|