Files
everything-claude-code/scripts/discord/ecc-bot.mjs
ECC Test 29edd57708 release: 2.0.0 — the agent harness operating system
Graduate 2.0.0-rc.1 to stable. Bump version across package, plugin,
marketplace, OpenCode, agent metadata, VERSION, and all localized docs.
Add 2.0.0 release notes + README sections (en/zh/pt-BR/tr), CHANGELOG
entry, and the ECC community Discord bot (dependency-free gateway client
+ guild command registrar). Update copilot-support and release-surface
tests for the sponsored-review migration and the 2.0.0 surface.
2026-06-09 21:40:40 -04:00

223 lines
8.2 KiB
JavaScript

#!/usr/bin/env node
// ECC community Discord bot — dependency-free (Node 22+ native WebSocket).
// Slash commands: /ecc /help /skill /docs /release
//
// Env: DISCORD_BOT_TOKEN (required), DISCORD_APP_ID (required),
// ECC_REPO (path to local clone, default ~/GitHub/ECC/everything-claude-code),
// DISCORD_INVITE (optional, shown in /ecc)
//
// Crash-only design: any gateway close, error, or missed heartbeat ack exits
// the process; the launchd/pm2 supervisor restarts it with a fresh identify.
// Register commands first: node scripts/discord/register-commands.mjs
'use strict';
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const TOKEN = process.env.DISCORD_BOT_TOKEN;
const APP_ID = process.env.DISCORD_APP_ID;
if (!TOKEN || !APP_ID) {
console.error('missing DISCORD_BOT_TOKEN / DISCORD_APP_ID');
process.exit(1);
}
const REPO = process.env.ECC_REPO || join(homedir(), 'GitHub/ECC/everything-claude-code');
const REPO_URL = 'https://github.com/affaan-m/ECC';
const INVITE = process.env.DISCORD_INVITE || '';
const API = 'https://discord.com/api/v10';
const log = (...a) => console.log(new Date().toISOString(), ...a);
// ---------- skill + docs lookup (local clone as the data source) ----------
function parseFrontmatter(text) {
const m = text.match(/^---\n([\s\S]*?)\n---/);
if (!m) return {};
const out = {};
for (const line of m[1].split('\n')) {
const kv = line.match(/^(\w[\w-]*):\s*(.+)$/);
if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, '');
}
return out;
}
function loadSkills() {
const dir = join(REPO, 'skills');
if (!existsSync(dir)) return [];
const skills = [];
for (const name of readdirSync(dir)) {
const md = join(dir, name, 'SKILL.md');
if (!existsSync(md)) continue;
try {
const fm = parseFrontmatter(readFileSync(md, 'utf8'));
skills.push({ name, description: fm.description || '(no description)' });
} catch { /* unreadable skill dirs are skipped, not fatal */ }
}
return skills;
}
function findSkill(query) {
const q = query.toLowerCase().trim().replace(/\s+/g, '-');
const skills = loadSkills();
const exact = skills.find(s => s.name === q);
const ranked = exact
? [exact, ...skills.filter(s => s !== exact && s.name.includes(q))]
: skills.filter(s => s.name.includes(q) || s.description.toLowerCase().includes(query.toLowerCase()));
return ranked.slice(0, 5);
}
function searchDocs(query) {
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
const hits = [];
const roots = ['docs', 'README.md'];
const walk = rel => {
const abs = join(REPO, rel);
if (!existsSync(abs)) return;
if (statSync(abs).isDirectory()) {
for (const f of readdirSync(abs)) walk(join(rel, f));
return;
}
if (!rel.endsWith('.md')) return;
const nameScore = terms.filter(t => rel.toLowerCase().includes(t)).length;
let score = nameScore * 3;
if (nameScore < terms.length) {
try {
const head = readFileSync(abs, 'utf8').slice(0, 4000).toLowerCase();
score += terms.filter(t => head.includes(t)).length;
} catch { /* skip unreadable */ }
}
if (score > 0) hits.push({ rel, score });
};
for (const r of roots) walk(r);
return hits.sort((a, b) => b.score - a.score).slice(0, 5);
}
// ---------- command handlers ----------
const HELP = [
'**ECC bot commands**',
'- `/ecc` — what ECC is + all the links',
'- `/skill name:<query>` — look up an ECC skill',
'- `/docs query:<terms>` — search the ECC docs',
'- `/release` — latest ECC release',
'- `/help` — this message',
].join('\n');
const handlers = {
ecc: () => [
'**Everything Claude Code (ECC)** — the agent harness performance system.',
'Skills, agents, rules, hooks, MCP conventions, and operator workflows that move across Claude Code, Codex, OpenCode, Cursor, Gemini, and Zed.',
'',
`- repo: ${REPO_URL}`,
'- site: https://ecc.tools',
`- install: \`/plugin marketplace add affaan-m/everything-claude-code\` then \`/plugin install ecc\``,
INVITE ? `- invite a friend: ${INVITE}` : '',
].filter(Boolean).join('\n'),
help: () => HELP,
skill: (options) => {
const query = options.find(o => o.name === 'name')?.value || '';
const found = findSkill(query);
if (!found.length) return `no skill matching \`${query}\` — browse all: ${REPO_URL}/tree/main/skills`;
const [top, ...rest] = found;
return [
`**${top.name}** — ${top.description}`,
`${REPO_URL}/tree/main/skills/${top.name}`,
rest.length ? `\nalso close: ${rest.map(s => `\`${s.name}\``).join(', ')}` : '',
].filter(Boolean).join('\n');
},
docs: (options) => {
const query = options.find(o => o.name === 'query')?.value || '';
const hits = searchDocs(query);
if (!hits.length) return `nothing found for \`${query}\` — try ${REPO_URL}/tree/main/docs`;
return [`**docs matching \`${query}\`:**`, ...hits.map(h => `- ${REPO_URL}/blob/main/${h.rel.replace(/\\/g, '/')}`)].join('\n');
},
release: async () => {
const res = await fetch('https://api.github.com/repos/affaan-m/ECC/releases/latest', {
headers: { 'User-Agent': 'ecc-discord-bot' },
});
if (!res.ok) return `couldn't reach GitHub (${res.status}) — ${REPO_URL}/releases`;
const r = await res.json();
return `**${r.name || r.tag_name}**\n${r.html_url}`;
},
};
async function respond(interaction) {
const name = interaction.data?.name;
const handler = handlers[name];
const url = `${API}/interactions/${interaction.id}/${interaction.token}/callback`;
if (!handler) {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 4, data: { content: `unknown command \`${name}\`` } }),
});
return;
}
try {
const content = await handler(interaction.data?.options || []);
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 4, data: { content: String(content).slice(0, 1990) } }),
});
log('handled', `/${name}`);
} catch (err) {
log('handler error', name, err.message);
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 4, data: { content: 'something broke handling that — try again in a minute' } }),
}).catch(() => {});
}
}
// ---------- gateway (crash-only: exit on any failure, supervisor restarts) ----------
let seq = null;
let acked = true;
async function main() {
const gw = await fetch(`${API}/gateway/bot`, { headers: { Authorization: `Bot ${TOKEN}` } }).then(r => r.json());
if (!gw.url) { console.error('gateway discovery failed:', JSON.stringify(gw).slice(0, 200)); process.exit(1); }
const ws = new WebSocket(`${gw.url}?v=10&encoding=json`);
const send = payload => ws.send(JSON.stringify(payload));
const die = reason => { log('exiting:', reason); process.exit(1); };
ws.onmessage = ev => {
const msg = JSON.parse(ev.data);
if (msg.s) seq = msg.s;
switch (msg.op) {
case 10: { // HELLO
const interval = msg.d.heartbeat_interval;
setTimeout(() => {
send({ op: 1, d: seq });
setInterval(() => {
if (!acked) die('missed heartbeat ack');
acked = false;
send({ op: 1, d: seq });
}, interval);
}, interval * Math.random());
send({ op: 2, d: { token: TOKEN, intents: 1, properties: { os: 'darwin', browser: 'ecc-bot', device: 'ecc-bot' } } });
break;
}
case 11: acked = true; break; // HEARTBEAT_ACK
case 1: send({ op: 1, d: seq }); break; // server-requested heartbeat
case 7: die('server requested reconnect'); break;
case 9: die('invalid session'); break;
case 0:
if (msg.t === 'READY') log(`READY as ${msg.d.user.username}#${msg.d.user.discriminator}`);
if (msg.t === 'INTERACTION_CREATE' && msg.d.type === 2) respond(msg.d);
break;
default: break;
}
};
ws.onclose = ev => die(`gateway closed (${ev.code})`);
ws.onerror = () => die('gateway error');
}
main().catch(err => { console.error('fatal:', err.message); process.exit(1); });