@harbinger-ai/harbinger 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/README.md +406 -0
- package/agents/README.md +76 -0
- package/agents/_template/CONFIG.yaml +7 -0
- package/agents/_template/HEARTBEAT.md +59 -0
- package/agents/_template/IDENTITY.md +4 -0
- package/agents/_template/SKILLS.md +1 -0
- package/agents/_template/SOUL.md +25 -0
- package/agents/_template/TOOLS.md +3 -0
- package/agents/binary-reverser/CONFIG.yaml +21 -0
- package/agents/binary-reverser/HEARTBEAT.md +65 -0
- package/agents/binary-reverser/IDENTITY.md +1 -0
- package/agents/binary-reverser/SKILLS.md +1 -0
- package/agents/binary-reverser/SOUL.md +23 -0
- package/agents/binary-reverser/TOOLS.md +99 -0
- package/agents/browser-agent/CONFIG.yaml +20 -0
- package/agents/browser-agent/HEARTBEAT.md +79 -0
- package/agents/browser-agent/IDENTITY.md +5 -0
- package/agents/browser-agent/SKILLS.md +86 -0
- package/agents/browser-agent/SOUL.md +23 -0
- package/agents/browser-agent/TOOLS.md +186 -0
- package/agents/cloud-infiltrator/CONFIG.yaml +22 -0
- package/agents/cloud-infiltrator/HEARTBEAT.md +78 -0
- package/agents/cloud-infiltrator/IDENTITY.md +1 -0
- package/agents/cloud-infiltrator/SKILLS.md +1 -0
- package/agents/cloud-infiltrator/SOUL.md +23 -0
- package/agents/cloud-infiltrator/TOOLS.md +68 -0
- package/agents/coding-assistant/CONFIG.yaml +22 -0
- package/agents/coding-assistant/HEARTBEAT.md +57 -0
- package/agents/coding-assistant/IDENTITY.md +5 -0
- package/agents/coding-assistant/SKILLS.md +69 -0
- package/agents/coding-assistant/SOUL.md +60 -0
- package/agents/coding-assistant/TOOLS.md +168 -0
- package/agents/learning-agent/CONFIG.yaml +21 -0
- package/agents/learning-agent/HEARTBEAT.md +63 -0
- package/agents/learning-agent/IDENTITY.md +5 -0
- package/agents/learning-agent/SKILLS.md +86 -0
- package/agents/learning-agent/SOUL.md +77 -0
- package/agents/learning-agent/TOOLS.md +145 -0
- package/agents/maintainer/CONFIG.yaml +31 -0
- package/agents/maintainer/HEARTBEAT.md +28 -0
- package/agents/maintainer/IDENTITY.md +33 -0
- package/agents/maintainer/SKILLS.md +24 -0
- package/agents/maintainer/SOUL.md +61 -0
- package/agents/maintainer/TOOLS.md +29 -0
- package/agents/maintainer/lib/engine.js +279 -0
- package/agents/maintainer/lib/safe-fixer.js +183 -0
- package/agents/morning-brief/CONFIG.yaml +22 -0
- package/agents/morning-brief/HEARTBEAT.md +60 -0
- package/agents/morning-brief/IDENTITY.md +5 -0
- package/agents/morning-brief/SKILLS.md +56 -0
- package/agents/morning-brief/SOUL.md +64 -0
- package/agents/morning-brief/TOOLS.md +112 -0
- package/agents/osint-detective/CONFIG.yaml +24 -0
- package/agents/osint-detective/HEARTBEAT.md +66 -0
- package/agents/osint-detective/IDENTITY.md +1 -0
- package/agents/osint-detective/SKILLS.md +1 -0
- package/agents/osint-detective/SOUL.md +23 -0
- package/agents/osint-detective/TOOLS.md +81 -0
- package/agents/recon-scout/CONFIG.yaml +22 -0
- package/agents/recon-scout/HEARTBEAT.md +79 -0
- package/agents/recon-scout/IDENTITY.md +1 -0
- package/agents/recon-scout/SKILLS.md +1 -0
- package/agents/recon-scout/SOUL.md +23 -0
- package/agents/recon-scout/TOOLS.md +93 -0
- package/agents/report-writer/CONFIG.yaml +21 -0
- package/agents/report-writer/HEARTBEAT.md +63 -0
- package/agents/report-writer/IDENTITY.md +1 -0
- package/agents/report-writer/SKILLS.md +1 -0
- package/agents/report-writer/SOUL.md +23 -0
- package/agents/report-writer/TOOLS.md +69 -0
- package/agents/shared/README.md +13 -0
- package/agents/web-hacker/CONFIG.yaml +24 -0
- package/agents/web-hacker/HEARTBEAT.md +78 -0
- package/agents/web-hacker/IDENTITY.md +1 -0
- package/agents/web-hacker/SKILLS.md +1 -0
- package/agents/web-hacker/SOUL.md +23 -0
- package/agents/web-hacker/TOOLS.md +86 -0
- package/api/CLAUDE.md +19 -0
- package/api/index.js +274 -0
- package/bin/cli.js +620 -0
- package/bin/local.sh +31 -0
- package/bin/postinstall.js +63 -0
- package/config/index.js +24 -0
- package/config/instrumentation.js +93 -0
- package/drizzle/0000_initial.sql +52 -0
- package/drizzle/0001_bounty_and_registry.sql +82 -0
- package/drizzle/0002_sync_columns.sql +7 -0
- package/drizzle/0003_graceful_bloodscream.sql +86 -0
- package/drizzle/meta/0000_snapshot.json +321 -0
- package/drizzle/meta/0003_snapshot.json +878 -0
- package/drizzle/meta/_journal.json +34 -0
- package/drizzle/relations.ts +3 -0
- package/drizzle/schema.ts +145 -0
- package/lib/actions.js +47 -0
- package/lib/agents.js +166 -0
- package/lib/ai/agent.js +96 -0
- package/lib/ai/autonomous-engine.js +261 -0
- package/lib/ai/index.js +359 -0
- package/lib/ai/model-router.js +254 -0
- package/lib/ai/model.js +73 -0
- package/lib/ai/tools.js +84 -0
- package/lib/auth/actions.js +28 -0
- package/lib/auth/config.js +27 -0
- package/lib/auth/edge-config.js +27 -0
- package/lib/auth/index.js +27 -0
- package/lib/auth/middleware.js +53 -0
- package/lib/bounty/actions.js +119 -0
- package/lib/bounty/findings.js +64 -0
- package/lib/bounty/programs.js +34 -0
- package/lib/bounty/sync-targets.js +267 -0
- package/lib/bounty/targets.js +33 -0
- package/lib/channels/base.js +56 -0
- package/lib/channels/index.js +15 -0
- package/lib/channels/telegram.js +148 -0
- package/lib/chat/actions.js +288 -0
- package/lib/chat/api.js +135 -0
- package/lib/chat/components/app-sidebar.js +237 -0
- package/lib/chat/components/app-sidebar.jsx +289 -0
- package/lib/chat/components/chat-header.js +27 -0
- package/lib/chat/components/chat-header.jsx +37 -0
- package/lib/chat/components/chat-input.js +230 -0
- package/lib/chat/components/chat-input.jsx +228 -0
- package/lib/chat/components/chat-nav-context.js +11 -0
- package/lib/chat/components/chat-nav-context.jsx +11 -0
- package/lib/chat/components/chat-page.js +81 -0
- package/lib/chat/components/chat-page.jsx +100 -0
- package/lib/chat/components/chat.js +150 -0
- package/lib/chat/components/chat.jsx +182 -0
- package/lib/chat/components/chats-page.js +302 -0
- package/lib/chat/components/chats-page.jsx +330 -0
- package/lib/chat/components/crons-page.js +172 -0
- package/lib/chat/components/crons-page.jsx +244 -0
- package/lib/chat/components/enhanced-tool-call.js +103 -0
- package/lib/chat/components/enhanced-tool-call.jsx +139 -0
- package/lib/chat/components/findings-page.js +175 -0
- package/lib/chat/components/findings-page.jsx +214 -0
- package/lib/chat/components/greeting.js +22 -0
- package/lib/chat/components/greeting.jsx +26 -0
- package/lib/chat/components/icons.js +777 -0
- package/lib/chat/components/icons.jsx +741 -0
- package/lib/chat/components/index.js +26 -0
- package/lib/chat/components/mcp-page.js +260 -0
- package/lib/chat/components/mcp-page.jsx +355 -0
- package/lib/chat/components/message.js +289 -0
- package/lib/chat/components/message.jsx +315 -0
- package/lib/chat/components/messages.js +66 -0
- package/lib/chat/components/messages.jsx +77 -0
- package/lib/chat/components/notifications-page.js +56 -0
- package/lib/chat/components/notifications-page.jsx +87 -0
- package/lib/chat/components/page-layout.js +21 -0
- package/lib/chat/components/page-layout.jsx +28 -0
- package/lib/chat/components/registry-page.js +222 -0
- package/lib/chat/components/registry-page.jsx +255 -0
- package/lib/chat/components/settings-layout.js +40 -0
- package/lib/chat/components/settings-layout.jsx +54 -0
- package/lib/chat/components/settings-secrets-page.js +216 -0
- package/lib/chat/components/settings-secrets-page.jsx +264 -0
- package/lib/chat/components/sidebar-history-item.js +132 -0
- package/lib/chat/components/sidebar-history-item.jsx +113 -0
- package/lib/chat/components/sidebar-history.js +115 -0
- package/lib/chat/components/sidebar-history.jsx +157 -0
- package/lib/chat/components/sidebar-user-nav.js +63 -0
- package/lib/chat/components/sidebar-user-nav.jsx +73 -0
- package/lib/chat/components/status-bar.js +39 -0
- package/lib/chat/components/status-bar.jsx +51 -0
- package/lib/chat/components/swarm-page.js +157 -0
- package/lib/chat/components/swarm-page.jsx +210 -0
- package/lib/chat/components/targets-page.js +376 -0
- package/lib/chat/components/targets-page.jsx +389 -0
- package/lib/chat/components/tool-call.js +86 -0
- package/lib/chat/components/tool-call.jsx +104 -0
- package/lib/chat/components/tool-panel.js +107 -0
- package/lib/chat/components/tool-panel.jsx +145 -0
- package/lib/chat/components/triggers-page.js +153 -0
- package/lib/chat/components/triggers-page.jsx +221 -0
- package/lib/chat/components/ui/confirm-dialog.js +53 -0
- package/lib/chat/components/ui/confirm-dialog.jsx +57 -0
- package/lib/chat/components/ui/dropdown-menu.js +98 -0
- package/lib/chat/components/ui/dropdown-menu.jsx +116 -0
- package/lib/chat/components/ui/rename-dialog.js +74 -0
- package/lib/chat/components/ui/rename-dialog.jsx +72 -0
- package/lib/chat/components/ui/scroll-area.js +13 -0
- package/lib/chat/components/ui/scroll-area.jsx +17 -0
- package/lib/chat/components/ui/separator.js +21 -0
- package/lib/chat/components/ui/separator.jsx +18 -0
- package/lib/chat/components/ui/sheet.js +75 -0
- package/lib/chat/components/ui/sheet.jsx +95 -0
- package/lib/chat/components/ui/sidebar.js +227 -0
- package/lib/chat/components/ui/sidebar.jsx +245 -0
- package/lib/chat/components/ui/tooltip.js +56 -0
- package/lib/chat/components/ui/tooltip.jsx +66 -0
- package/lib/chat/components/upgrade-dialog.js +151 -0
- package/lib/chat/components/upgrade-dialog.jsx +170 -0
- package/lib/chat/utils.js +11 -0
- package/lib/cron.js +246 -0
- package/lib/db/api-keys.js +163 -0
- package/lib/db/chats.js +145 -0
- package/lib/db/index.js +52 -0
- package/lib/db/notifications.js +99 -0
- package/lib/db/schema.js +145 -0
- package/lib/db/update-check.js +96 -0
- package/lib/db/users.js +89 -0
- package/lib/mcp/actions.js +104 -0
- package/lib/mcp/client.js +79 -0
- package/lib/mcp/handler.js +57 -0
- package/lib/mcp/server.js +165 -0
- package/lib/paths.js +46 -0
- package/lib/registry/actions.js +164 -0
- package/lib/registry/catalog.js +137 -0
- package/lib/registry/tools.js +71 -0
- package/lib/tools/create-job.js +99 -0
- package/lib/tools/github.js +217 -0
- package/lib/tools/openai.js +35 -0
- package/lib/tools/telegram.js +292 -0
- package/lib/triggers.js +118 -0
- package/lib/utils/render-md.js +102 -0
- package/package.json +103 -0
- package/setup/lib/auth.mjs +81 -0
- package/setup/lib/env.mjs +21 -0
- package/setup/lib/fs-utils.mjs +20 -0
- package/setup/lib/github.mjs +149 -0
- package/setup/lib/prerequisites.mjs +155 -0
- package/setup/lib/prompts.mjs +267 -0
- package/setup/lib/providers.mjs +48 -0
- package/setup/lib/sync.mjs +125 -0
- package/setup/lib/targets.mjs +45 -0
- package/setup/lib/telegram-verify.mjs +63 -0
- package/setup/lib/telegram.mjs +76 -0
- package/setup/setup-telegram.mjs +264 -0
- package/setup/setup.mjs +842 -0
- package/templates/.dockerignore +5 -0
- package/templates/.env.example +63 -0
- package/templates/.github/workflows/auto-merge.yml +117 -0
- package/templates/.github/workflows/build-image.yml +36 -0
- package/templates/.github/workflows/notify-job-failed.yml +64 -0
- package/templates/.github/workflows/notify-pr-complete.yml +119 -0
- package/templates/.github/workflows/rebuild-event-handler.yml +121 -0
- package/templates/.github/workflows/run-job.yml +89 -0
- package/templates/.github/workflows/upgrade-event-handler.yml +62 -0
- package/templates/.gitignore.template +45 -0
- package/templates/.pi/extensions/env-sanitizer/index.ts +48 -0
- package/templates/.pi/extensions/env-sanitizer/package.json +5 -0
- package/templates/CLAUDE.md +29 -0
- package/templates/CLAUDE.md.template +307 -0
- package/templates/app/api/[...thepopebot]/route.js +1 -0
- package/templates/app/api/auth/[...nextauth]/route.js +1 -0
- package/templates/app/chat/[chatId]/page.js +8 -0
- package/templates/app/chats/page.js +7 -0
- package/templates/app/components/ascii-logo.jsx +10 -0
- package/templates/app/components/login-form.jsx +92 -0
- package/templates/app/components/setup-form.jsx +82 -0
- package/templates/app/components/theme-provider.jsx +11 -0
- package/templates/app/components/theme-toggle.jsx +38 -0
- package/templates/app/components/ui/button.jsx +21 -0
- package/templates/app/components/ui/card.jsx +23 -0
- package/templates/app/components/ui/input.jsx +10 -0
- package/templates/app/components/ui/label.jsx +10 -0
- package/templates/app/crons/page.js +5 -0
- package/templates/app/findings/page.js +7 -0
- package/templates/app/globals.css +90 -0
- package/templates/app/layout.js +19 -0
- package/templates/app/login/page.js +15 -0
- package/templates/app/notifications/page.js +7 -0
- package/templates/app/page.js +7 -0
- package/templates/app/settings/crons/page.js +5 -0
- package/templates/app/settings/layout.js +7 -0
- package/templates/app/settings/mcp/page.js +5 -0
- package/templates/app/settings/page.js +5 -0
- package/templates/app/settings/secrets/page.js +5 -0
- package/templates/app/settings/triggers/page.js +5 -0
- package/templates/app/stream/chat/route.js +1 -0
- package/templates/app/swarm/page.js +7 -0
- package/templates/app/targets/page.js +7 -0
- package/templates/app/toolbox/page.js +7 -0
- package/templates/app/triggers/page.js +5 -0
- package/templates/config/AGENT.md +34 -0
- package/templates/config/CRONS.json +56 -0
- package/templates/config/EVENT_HANDLER.md +224 -0
- package/templates/config/HEARTBEAT.md +3 -0
- package/templates/config/JOB_SUMMARY.md +130 -0
- package/templates/config/MCP_SERVERS.json +1 -0
- package/templates/config/SKILL_BUILDING_GUIDE.md +90 -0
- package/templates/config/SOUL.md +17 -0
- package/templates/config/TRIGGERS.json +58 -0
- package/templates/docker/event-handler/Dockerfile +20 -0
- package/templates/docker/event-handler/ecosystem.config.cjs +8 -0
- package/templates/docker/job-claude-code/Dockerfile +34 -0
- package/templates/docker/job-claude-code/entrypoint.sh +139 -0
- package/templates/docker/job-pi-coding-agent/Dockerfile +44 -0
- package/templates/docker/job-pi-coding-agent/entrypoint.sh +163 -0
- package/templates/docker-compose.yml +63 -0
- package/templates/instrumentation.js +6 -0
- package/templates/middleware.js +1 -0
- package/templates/next.config.mjs +3 -0
- package/templates/postcss.config.mjs +5 -0
- package/templates/skills/LICENSE +21 -0
- package/templates/skills/README.md +119 -0
- package/templates/skills/brave-search/SKILL.md +79 -0
- package/templates/skills/brave-search/content.js +86 -0
- package/templates/skills/brave-search/package-lock.json +621 -0
- package/templates/skills/brave-search/package.json +14 -0
- package/templates/skills/brave-search/search.js +199 -0
- package/templates/skills/browser-tools/SKILL.md +196 -0
- package/templates/skills/browser-tools/browser-content.js +103 -0
- package/templates/skills/browser-tools/browser-cookies.js +35 -0
- package/templates/skills/browser-tools/browser-eval.js +53 -0
- package/templates/skills/browser-tools/browser-hn-scraper.js +108 -0
- package/templates/skills/browser-tools/browser-nav.js +44 -0
- package/templates/skills/browser-tools/browser-pick.js +162 -0
- package/templates/skills/browser-tools/browser-screenshot.js +34 -0
- package/templates/skills/browser-tools/browser-start.js +87 -0
- package/templates/skills/browser-tools/package-lock.json +2556 -0
- package/templates/skills/browser-tools/package.json +19 -0
- package/templates/skills/llm-secrets/SKILL.md +34 -0
- package/templates/skills/llm-secrets/llm-secrets.js +33 -0
- package/templates/skills/modify-self/SKILL.md +12 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub REST API helper with authentication
|
|
3
|
+
* @param {string} endpoint - API endpoint (e.g., '/repos/owner/repo/...')
|
|
4
|
+
* @param {object} options - Fetch options (method, body, headers)
|
|
5
|
+
* @returns {Promise<object>} - Parsed JSON response
|
|
6
|
+
*/
|
|
7
|
+
async function githubApi(endpoint, options = {}) {
|
|
8
|
+
const { GH_TOKEN } = process.env;
|
|
9
|
+
if (!GH_TOKEN) throw new Error('GH_TOKEN environment variable is required for GitHub API calls');
|
|
10
|
+
const res = await fetch(`https://api.github.com${endpoint}`, {
|
|
11
|
+
...options,
|
|
12
|
+
headers: {
|
|
13
|
+
'Authorization': `Bearer ${GH_TOKEN}`,
|
|
14
|
+
'Accept': 'application/vnd.github+json',
|
|
15
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
16
|
+
...options.headers,
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
const error = await res.text();
|
|
22
|
+
throw new Error(`GitHub API error: ${res.status} ${error}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return res.json();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get workflow runs with optional status and workflow filter
|
|
30
|
+
* @param {string} [status] - Filter by status (in_progress, queued, completed)
|
|
31
|
+
* @param {string} [workflow] - Workflow filename to scope to (e.g., 'run-job.yml')
|
|
32
|
+
* @returns {Promise<object>} - Workflow runs response
|
|
33
|
+
*/
|
|
34
|
+
async function getWorkflowRuns(status, { workflow, page = 1, perPage = 100 } = {}) {
|
|
35
|
+
const { GH_OWNER, GH_REPO } = process.env;
|
|
36
|
+
const params = new URLSearchParams();
|
|
37
|
+
if (status) params.set('status', status);
|
|
38
|
+
params.set('per_page', String(perPage));
|
|
39
|
+
params.set('page', String(page));
|
|
40
|
+
|
|
41
|
+
const query = params.toString();
|
|
42
|
+
const path = workflow
|
|
43
|
+
? `/repos/${GH_OWNER}/${GH_REPO}/actions/workflows/${workflow}/runs?${query}`
|
|
44
|
+
: `/repos/${GH_OWNER}/${GH_REPO}/actions/runs?${query}`;
|
|
45
|
+
return githubApi(path);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get jobs for a specific workflow run
|
|
50
|
+
* @param {number} runId - Workflow run ID
|
|
51
|
+
* @returns {Promise<object>} - Jobs response with steps
|
|
52
|
+
*/
|
|
53
|
+
async function getWorkflowRunJobs(runId) {
|
|
54
|
+
const { GH_OWNER, GH_REPO } = process.env;
|
|
55
|
+
return githubApi(`/repos/${GH_OWNER}/${GH_REPO}/actions/runs/${runId}/jobs`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get job status for running/recent jobs
|
|
60
|
+
* @param {string} [jobId] - Optional specific job ID to filter by
|
|
61
|
+
* @returns {Promise<object>} - Status summary with jobs array
|
|
62
|
+
*/
|
|
63
|
+
async function getJobStatus(jobId) {
|
|
64
|
+
// Fetch both in_progress and queued runs (scoped to run-job.yml)
|
|
65
|
+
const [inProgress, queued] = await Promise.all([
|
|
66
|
+
getWorkflowRuns('in_progress', { workflow: 'run-job.yml' }),
|
|
67
|
+
getWorkflowRuns('queued', { workflow: 'run-job.yml' }),
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const allRuns = [...(inProgress.workflow_runs || []), ...(queued.workflow_runs || [])];
|
|
71
|
+
|
|
72
|
+
// Filter to only job/* branches
|
|
73
|
+
const jobRuns = allRuns.filter(run => run.head_branch?.startsWith('job/'));
|
|
74
|
+
|
|
75
|
+
// If specific job requested, filter further
|
|
76
|
+
const filteredRuns = jobId
|
|
77
|
+
? jobRuns.filter(run => run.head_branch === `job/${jobId}`)
|
|
78
|
+
: jobRuns;
|
|
79
|
+
|
|
80
|
+
// Get detailed job info for each run
|
|
81
|
+
const jobs = await Promise.all(
|
|
82
|
+
filteredRuns.map(async (run) => {
|
|
83
|
+
const extractedJobId = run.head_branch.slice(4); // Remove 'job/' prefix
|
|
84
|
+
const startedAt = new Date(run.created_at);
|
|
85
|
+
const durationMinutes = Math.round((Date.now() - startedAt.getTime()) / 60000);
|
|
86
|
+
|
|
87
|
+
let currentStep = null;
|
|
88
|
+
let stepsCompleted = 0;
|
|
89
|
+
let stepsTotal = 0;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const jobsData = await getWorkflowRunJobs(run.id);
|
|
93
|
+
if (jobsData.jobs?.length > 0) {
|
|
94
|
+
const job = jobsData.jobs[0];
|
|
95
|
+
stepsTotal = job.steps?.length || 0;
|
|
96
|
+
stepsCompleted = job.steps?.filter(s => s.status === 'completed').length || 0;
|
|
97
|
+
currentStep = job.steps?.find(s => s.status === 'in_progress')?.name || null;
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// Jobs endpoint may fail if run hasn't started yet
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
job_id: extractedJobId,
|
|
105
|
+
branch: run.head_branch,
|
|
106
|
+
status: run.status,
|
|
107
|
+
started_at: run.created_at,
|
|
108
|
+
duration_minutes: durationMinutes,
|
|
109
|
+
current_step: currentStep,
|
|
110
|
+
steps_completed: stepsCompleted,
|
|
111
|
+
steps_total: stepsTotal,
|
|
112
|
+
run_id: run.id,
|
|
113
|
+
};
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Count only job/* branches, not all workflows
|
|
118
|
+
const runningCount = jobs.filter(j => j.status === 'in_progress').length;
|
|
119
|
+
const queuedCount = jobs.filter(j => j.status === 'queued').length;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
jobs,
|
|
123
|
+
queued: queuedCount,
|
|
124
|
+
running: runningCount,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get full swarm status: unified list of all workflow runs with counts
|
|
130
|
+
* @param {number} [page=1] - Page number for pagination
|
|
131
|
+
* @returns {Promise<object>} - { runs, hasMore, counts }
|
|
132
|
+
*/
|
|
133
|
+
async function getSwarmStatus(page = 1) {
|
|
134
|
+
const data = await getWorkflowRuns(null, { page, perPage: 25 });
|
|
135
|
+
|
|
136
|
+
const runs = (data.workflow_runs || []).map(run => ({
|
|
137
|
+
run_id: run.id,
|
|
138
|
+
branch: run.head_branch,
|
|
139
|
+
status: run.status,
|
|
140
|
+
conclusion: run.conclusion,
|
|
141
|
+
workflow_name: run.name,
|
|
142
|
+
started_at: run.created_at,
|
|
143
|
+
updated_at: run.updated_at,
|
|
144
|
+
duration_seconds: Math.round((Date.now() - new Date(run.created_at).getTime()) / 1000),
|
|
145
|
+
html_url: run.html_url,
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
runs,
|
|
150
|
+
hasMore: page * 25 < (data.total_count || 0),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Trigger a workflow via workflow_dispatch
|
|
156
|
+
* @param {string} workflowId - Workflow file name (e.g., 'upgrade-event-handler.yml')
|
|
157
|
+
* @param {string} [ref='main'] - Git ref to run the workflow on
|
|
158
|
+
* @param {object} [inputs={}] - Workflow inputs
|
|
159
|
+
*/
|
|
160
|
+
async function triggerWorkflowDispatch(workflowId, ref = 'main', inputs = {}) {
|
|
161
|
+
const { GH_OWNER, GH_REPO } = process.env;
|
|
162
|
+
const res = await fetch(
|
|
163
|
+
`https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/actions/workflows/${workflowId}/dispatches`,
|
|
164
|
+
{
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: {
|
|
167
|
+
'Authorization': `Bearer ${process.env.GH_TOKEN}`,
|
|
168
|
+
'Accept': 'application/vnd.github+json',
|
|
169
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
},
|
|
172
|
+
body: JSON.stringify({ ref, inputs }),
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
if (!res.ok && res.status !== 204) {
|
|
176
|
+
const error = await res.text();
|
|
177
|
+
throw new Error(`GitHub API error: ${res.status} ${error}`);
|
|
178
|
+
}
|
|
179
|
+
return { success: true };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Fetch the session log (.jsonl) for a job from the GitHub repo at a specific commit.
|
|
184
|
+
* @param {string} jobId - The job ID (used to locate logs/{jobId}/)
|
|
185
|
+
* @param {string} commitSha - Git commit SHA to read from
|
|
186
|
+
* @returns {Promise<string>} - Log content or empty string if unavailable
|
|
187
|
+
*/
|
|
188
|
+
async function fetchJobLog(jobId, commitSha) {
|
|
189
|
+
if (!commitSha) return '';
|
|
190
|
+
const { GH_OWNER, GH_REPO } = process.env;
|
|
191
|
+
try {
|
|
192
|
+
const files = await githubApi(
|
|
193
|
+
`/repos/${GH_OWNER}/${GH_REPO}/contents/logs/${jobId}?ref=${encodeURIComponent(commitSha)}`
|
|
194
|
+
);
|
|
195
|
+
if (!Array.isArray(files)) return '';
|
|
196
|
+
const logFile = files.find(f => f.name.endsWith('.jsonl'));
|
|
197
|
+
if (!logFile || !logFile.download_url) return '';
|
|
198
|
+
const res = await fetch(logFile.download_url, {
|
|
199
|
+
headers: { 'Authorization': `Bearer ${process.env.GH_TOKEN}` },
|
|
200
|
+
});
|
|
201
|
+
if (!res.ok) return '';
|
|
202
|
+
return await res.text();
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error('Failed to fetch job log:', err.message);
|
|
205
|
+
return '';
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export {
|
|
210
|
+
githubApi,
|
|
211
|
+
getWorkflowRuns,
|
|
212
|
+
getWorkflowRunJobs,
|
|
213
|
+
getJobStatus,
|
|
214
|
+
getSwarmStatus,
|
|
215
|
+
triggerWorkflowDispatch,
|
|
216
|
+
fetchJobLog,
|
|
217
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if Whisper transcription is enabled
|
|
3
|
+
* @returns {boolean}
|
|
4
|
+
*/
|
|
5
|
+
function isWhisperEnabled() {
|
|
6
|
+
return Boolean(process.env.OPENAI_API_KEY);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Transcribe audio using OpenAI Whisper API
|
|
11
|
+
* @param {Buffer} audioBuffer - Audio file buffer
|
|
12
|
+
* @param {string} filename - Original filename (e.g., "voice.ogg")
|
|
13
|
+
* @returns {Promise<string>} Transcribed text
|
|
14
|
+
*/
|
|
15
|
+
async function transcribeAudio(audioBuffer, filename) {
|
|
16
|
+
const formData = new FormData();
|
|
17
|
+
formData.append('file', new Blob([audioBuffer]), filename);
|
|
18
|
+
formData.append('model', 'whisper-1');
|
|
19
|
+
|
|
20
|
+
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}` },
|
|
23
|
+
body: formData,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
const error = await response.text();
|
|
28
|
+
throw new Error(`OpenAI API error: ${response.status} ${error}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = await response.json();
|
|
32
|
+
return result.text;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { isWhisperEnabled, transcribeAudio };
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { Bot } from 'grammy';
|
|
2
|
+
import parseModePlugin from '@grammyjs/parse-mode';
|
|
3
|
+
const { hydrateReply } = parseModePlugin;
|
|
4
|
+
|
|
5
|
+
const MAX_LENGTH = 4096;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert markdown to Telegram-compatible HTML.
|
|
9
|
+
* Handles: code blocks, inline code, links, bold, italic, strikethrough, headings, lists.
|
|
10
|
+
* Strips unsupported HTML tags.
|
|
11
|
+
* @param {string} text - Markdown text
|
|
12
|
+
* @returns {string} Telegram HTML
|
|
13
|
+
*/
|
|
14
|
+
function markdownToTelegramHtml(text) {
|
|
15
|
+
if (!text) return '';
|
|
16
|
+
|
|
17
|
+
const placeholders = [];
|
|
18
|
+
function placeholder(content) {
|
|
19
|
+
const id = `\x00PH${placeholders.length}\x00`;
|
|
20
|
+
placeholders.push(content);
|
|
21
|
+
return id;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 1. Protect existing supported HTML tags (so they survive escaping)
|
|
25
|
+
text = text.replace(/<(\/?(b|i|s|u|code|pre|a)\b[^>]*)>/g, (match) => {
|
|
26
|
+
return placeholder(match);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// 2. Extract fenced code blocks (``` ... ```)
|
|
30
|
+
text = text.replace(/```[\w]*\n([\s\S]*?)```/g, (_, code) => {
|
|
31
|
+
return placeholder(`<pre>${escapeHtml(code.replace(/\n$/, ''))}</pre>`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// 3. Extract inline code (` ... `)
|
|
35
|
+
text = text.replace(/`([^`\n]+)`/g, (_, code) => {
|
|
36
|
+
return placeholder(`<code>${escapeHtml(code)}</code>`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 4. Escape remaining HTML special chars (after code + existing tags are protected)
|
|
40
|
+
text = text.replace(/&/g, '&');
|
|
41
|
+
text = text.replace(/</g, '<');
|
|
42
|
+
text = text.replace(/>/g, '>');
|
|
43
|
+
|
|
44
|
+
// 5. Links: [text](url)
|
|
45
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
46
|
+
|
|
47
|
+
// 6. Bold: **text** or __text__
|
|
48
|
+
text = text.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
|
49
|
+
text = text.replace(/__(.+?)__/g, '<b>$1</b>');
|
|
50
|
+
|
|
51
|
+
// 7. Italic: *text* or _text_ (but not inside words for underscores)
|
|
52
|
+
text = text.replace(/(?<!\w)\*([^*\n<]+)\*(?!\w)/g, '<i>$1</i>');
|
|
53
|
+
text = text.replace(/(?<!\w)_([^_\n<]+)_(?!\w)/g, '<i>$1</i>');
|
|
54
|
+
|
|
55
|
+
// 8. Strikethrough: ~~text~~
|
|
56
|
+
text = text.replace(/~~(.+?)~~/g, '<s>$1</s>');
|
|
57
|
+
|
|
58
|
+
// 9. Headings: ## text → bold (must be at line start)
|
|
59
|
+
text = text.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>');
|
|
60
|
+
|
|
61
|
+
// 10. List items: - item or * item → bullet
|
|
62
|
+
text = text.replace(/^[\s]*[-*]\s+/gm, '• ');
|
|
63
|
+
|
|
64
|
+
// 11. Numbered list items: 1. item → keep as-is (already plain text friendly)
|
|
65
|
+
|
|
66
|
+
// 12. Restore placeholders
|
|
67
|
+
for (let i = 0; i < placeholders.length; i++) {
|
|
68
|
+
text = text.replace(`\x00PH${i}\x00`, placeholders[i]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let bot = null;
|
|
75
|
+
let currentToken = null;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get or create bot instance
|
|
79
|
+
* @param {string} token - Bot token from @BotFather
|
|
80
|
+
* @returns {Bot} grammY Bot instance
|
|
81
|
+
*/
|
|
82
|
+
function getBot(token) {
|
|
83
|
+
if (!bot || currentToken !== token) {
|
|
84
|
+
bot = new Bot(token);
|
|
85
|
+
bot.use(hydrateReply);
|
|
86
|
+
currentToken = token;
|
|
87
|
+
}
|
|
88
|
+
return bot;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Set webhook for a Telegram bot
|
|
93
|
+
* @param {string} botToken - Bot token from @BotFather
|
|
94
|
+
* @param {string} webhookUrl - HTTPS URL to receive updates
|
|
95
|
+
* @param {string} [secretToken] - Optional secret token for verification
|
|
96
|
+
* @returns {Promise<boolean>} - Success status
|
|
97
|
+
*/
|
|
98
|
+
async function setWebhook(botToken, webhookUrl, secretToken) {
|
|
99
|
+
const b = getBot(botToken);
|
|
100
|
+
const options = {};
|
|
101
|
+
if (secretToken) {
|
|
102
|
+
options.secret_token = secretToken;
|
|
103
|
+
}
|
|
104
|
+
return b.api.setWebhook(webhookUrl, options);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Smart split text into chunks that fit Telegram's limit
|
|
109
|
+
* Prefers splitting at paragraph > newline > sentence > space
|
|
110
|
+
* @param {string} text - Text to split
|
|
111
|
+
* @param {number} maxLength - Maximum chunk length
|
|
112
|
+
* @returns {string[]} Array of chunks
|
|
113
|
+
*/
|
|
114
|
+
function smartSplit(text, maxLength = MAX_LENGTH) {
|
|
115
|
+
if (text.length <= maxLength) return [text];
|
|
116
|
+
|
|
117
|
+
const chunks = [];
|
|
118
|
+
let remaining = text;
|
|
119
|
+
|
|
120
|
+
while (remaining.length > 0) {
|
|
121
|
+
if (remaining.length <= maxLength) {
|
|
122
|
+
chunks.push(remaining);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const chunk = remaining.slice(0, maxLength);
|
|
127
|
+
let splitAt = -1;
|
|
128
|
+
|
|
129
|
+
// Try to split at natural boundaries (prefer earlier ones)
|
|
130
|
+
for (const delim of ['\n\n', '\n', '. ', ' ']) {
|
|
131
|
+
const idx = chunk.lastIndexOf(delim);
|
|
132
|
+
if (idx > maxLength * 0.3) {
|
|
133
|
+
splitAt = idx + delim.length;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (splitAt === -1) splitAt = maxLength;
|
|
139
|
+
|
|
140
|
+
chunks.push(remaining.slice(0, splitAt).trimEnd());
|
|
141
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return chunks;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Escape HTML special characters
|
|
149
|
+
* @param {string} text - Text to escape
|
|
150
|
+
* @returns {string} Escaped text
|
|
151
|
+
*/
|
|
152
|
+
function escapeHtml(text) {
|
|
153
|
+
if (!text) return '';
|
|
154
|
+
return text
|
|
155
|
+
.replace(/&/g, '&')
|
|
156
|
+
.replace(/</g, '<')
|
|
157
|
+
.replace(/>/g, '>');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Send a message to a Telegram chat with HTML formatting
|
|
162
|
+
* Automatically splits long messages
|
|
163
|
+
* @param {string} botToken - Bot token from @BotFather
|
|
164
|
+
* @param {number|string} chatId - Chat ID to send message to
|
|
165
|
+
* @param {string} text - Message text (HTML formatted)
|
|
166
|
+
* @param {Object} [options] - Additional options
|
|
167
|
+
* @param {boolean} [options.disablePreview] - Disable link previews
|
|
168
|
+
* @returns {Promise<Object>} - Last message sent
|
|
169
|
+
*/
|
|
170
|
+
async function sendMessage(botToken, chatId, text, options = {}) {
|
|
171
|
+
const b = getBot(botToken);
|
|
172
|
+
text = markdownToTelegramHtml(text);
|
|
173
|
+
// Strip HTML comments — Telegram's HTML parser doesn't support them
|
|
174
|
+
text = text.replace(/<!--[\s\S]*?-->/g, '');
|
|
175
|
+
const chunks = smartSplit(text, MAX_LENGTH);
|
|
176
|
+
|
|
177
|
+
let lastMessage;
|
|
178
|
+
for (const chunk of chunks) {
|
|
179
|
+
lastMessage = await b.api.sendMessage(chatId, chunk, {
|
|
180
|
+
parse_mode: 'HTML',
|
|
181
|
+
link_preview_options: { is_disabled: options.disablePreview ?? false },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return lastMessage;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Format a job notification message
|
|
190
|
+
* @param {Object} params - Notification parameters
|
|
191
|
+
* @param {string} params.jobId - Full job ID
|
|
192
|
+
* @param {boolean} params.success - Whether job succeeded
|
|
193
|
+
* @param {string} params.summary - Job summary text
|
|
194
|
+
* @param {string} params.prUrl - PR URL
|
|
195
|
+
* @returns {string} Formatted HTML message
|
|
196
|
+
*/
|
|
197
|
+
function formatJobNotification({ jobId, success, summary, prUrl }) {
|
|
198
|
+
const emoji = success ? '\u2705' : '\u26a0\ufe0f';
|
|
199
|
+
const status = success ? 'complete' : 'had issues';
|
|
200
|
+
const shortId = jobId.slice(0, 8);
|
|
201
|
+
|
|
202
|
+
return `${emoji} <b>Job ${shortId}</b> ${status}
|
|
203
|
+
|
|
204
|
+
${escapeHtml(summary)}
|
|
205
|
+
|
|
206
|
+
<a href="${prUrl}">View PR</a>`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Download a file from Telegram servers
|
|
211
|
+
* @param {string} botToken - Bot token from @BotFather
|
|
212
|
+
* @param {string} fileId - Telegram file_id
|
|
213
|
+
* @returns {Promise<{buffer: Buffer, filename: string}>}
|
|
214
|
+
*/
|
|
215
|
+
async function downloadFile(botToken, fileId) {
|
|
216
|
+
// Get file path from Telegram
|
|
217
|
+
const fileInfoRes = await fetch(
|
|
218
|
+
`https://api.telegram.org/bot${botToken}/getFile?file_id=${fileId}`
|
|
219
|
+
);
|
|
220
|
+
const fileInfo = await fileInfoRes.json();
|
|
221
|
+
if (!fileInfo.ok) {
|
|
222
|
+
throw new Error(`Telegram API error: ${fileInfo.description}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const filePath = fileInfo.result.file_path;
|
|
226
|
+
|
|
227
|
+
// Download file
|
|
228
|
+
const fileRes = await fetch(
|
|
229
|
+
`https://api.telegram.org/file/bot${botToken}/${filePath}`
|
|
230
|
+
);
|
|
231
|
+
const buffer = Buffer.from(await fileRes.arrayBuffer());
|
|
232
|
+
const filename = filePath.split('/').pop();
|
|
233
|
+
|
|
234
|
+
return { buffer, filename };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* React to a message with an emoji
|
|
239
|
+
* @param {string} botToken - Bot token from @BotFather
|
|
240
|
+
* @param {number|string} chatId - Chat ID
|
|
241
|
+
* @param {number} messageId - Message ID to react to
|
|
242
|
+
* @param {string} [emoji='\ud83d\udc4d'] - Emoji to react with
|
|
243
|
+
*/
|
|
244
|
+
async function reactToMessage(botToken, chatId, messageId, emoji = '\ud83d\udc4d') {
|
|
245
|
+
const b = getBot(botToken);
|
|
246
|
+
await b.api.setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Start a repeating typing indicator for a chat.
|
|
251
|
+
* Returns a stop function. The indicator naturally expires after 5s,
|
|
252
|
+
* so we re-send with random gaps (5.5-8s) to look human.
|
|
253
|
+
* @param {string} botToken - Bot token from @BotFather
|
|
254
|
+
* @param {number|string} chatId - Chat ID
|
|
255
|
+
* @returns {Function} Call to stop the typing indicator
|
|
256
|
+
*/
|
|
257
|
+
function startTypingIndicator(botToken, chatId) {
|
|
258
|
+
const b = getBot(botToken);
|
|
259
|
+
let timeout;
|
|
260
|
+
let stopped = false;
|
|
261
|
+
|
|
262
|
+
function scheduleNext() {
|
|
263
|
+
if (stopped) return;
|
|
264
|
+
const delay = 5500 + Math.random() * 2500;
|
|
265
|
+
timeout = setTimeout(() => {
|
|
266
|
+
if (stopped) return;
|
|
267
|
+
b.api.sendChatAction(chatId, 'typing').catch(() => {});
|
|
268
|
+
scheduleNext();
|
|
269
|
+
}, delay);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
b.api.sendChatAction(chatId, 'typing').catch(() => {});
|
|
273
|
+
scheduleNext();
|
|
274
|
+
|
|
275
|
+
return () => {
|
|
276
|
+
stopped = true;
|
|
277
|
+
clearTimeout(timeout);
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export {
|
|
282
|
+
getBot,
|
|
283
|
+
setWebhook,
|
|
284
|
+
sendMessage,
|
|
285
|
+
smartSplit,
|
|
286
|
+
escapeHtml,
|
|
287
|
+
markdownToTelegramHtml,
|
|
288
|
+
formatJobNotification,
|
|
289
|
+
downloadFile,
|
|
290
|
+
reactToMessage,
|
|
291
|
+
startTypingIndicator,
|
|
292
|
+
};
|
package/lib/triggers.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { triggersFile, triggersDir } from './paths.js';
|
|
3
|
+
import { executeAction } from './actions.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shell-escape a string to prevent command injection when used in shell commands.
|
|
7
|
+
* Wraps in single quotes and escapes any embedded single quotes.
|
|
8
|
+
*/
|
|
9
|
+
function shellEscape(str) {
|
|
10
|
+
return "'" + String(str).replace(/'/g, "'\\''") + "'";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Replace {{body.field}} templates with values from request context.
|
|
15
|
+
* @param {string} template - String with {{body.field}} placeholders
|
|
16
|
+
* @param {Object} context - { body, query, headers }
|
|
17
|
+
* @param {Object} [opts] - { escape: 'shell' } to shell-escape substituted values
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function resolveTemplate(template, context, opts = {}) {
|
|
21
|
+
const escape = opts.escape === 'shell' ? shellEscape : (v) => v;
|
|
22
|
+
return template.replace(/\{\{(\w+)(?:\.(\w+))?\}\}/g, (match, source, field) => {
|
|
23
|
+
const data = context[source];
|
|
24
|
+
if (data === undefined) return match;
|
|
25
|
+
if (!field) {
|
|
26
|
+
const raw = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
27
|
+
return escape(raw);
|
|
28
|
+
}
|
|
29
|
+
if (data[field] !== undefined) return escape(String(data[field]));
|
|
30
|
+
return match;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Execute all actions for a trigger (fire-and-forget)
|
|
36
|
+
* @param {Object} trigger - Trigger config object
|
|
37
|
+
* @param {Object} context - { body, query, headers }
|
|
38
|
+
*/
|
|
39
|
+
async function executeActions(trigger, context) {
|
|
40
|
+
for (const action of trigger.actions) {
|
|
41
|
+
try {
|
|
42
|
+
const resolved = { ...action };
|
|
43
|
+
// Shell-escape template values in commands to prevent injection from HTTP input
|
|
44
|
+
if (resolved.command) resolved.command = resolveTemplate(resolved.command, context, { escape: 'shell' });
|
|
45
|
+
if (resolved.job) resolved.job = resolveTemplate(resolved.job, context);
|
|
46
|
+
const result = await executeAction(resolved, { cwd: triggersDir, data: context.body });
|
|
47
|
+
console.log(`[TRIGGER] ${trigger.name}: ${result || 'ran'}`);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(`[TRIGGER] ${trigger.name}: error - ${err.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load triggers from TRIGGERS.json and return trigger map + fire function
|
|
56
|
+
* @returns {{ triggerMap: Map, fireTriggers: Function }}
|
|
57
|
+
*/
|
|
58
|
+
function loadTriggers() {
|
|
59
|
+
const triggerFile = triggersFile;
|
|
60
|
+
const triggerMap = new Map();
|
|
61
|
+
|
|
62
|
+
console.log('\n--- Triggers ---');
|
|
63
|
+
|
|
64
|
+
if (!fs.existsSync(triggerFile)) {
|
|
65
|
+
console.log('No TRIGGERS.json found');
|
|
66
|
+
console.log('----------------\n');
|
|
67
|
+
return { triggerMap, fireTriggers: () => {} };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const triggers = JSON.parse(fs.readFileSync(triggerFile, 'utf8'));
|
|
71
|
+
|
|
72
|
+
for (const trigger of triggers) {
|
|
73
|
+
if (trigger.enabled === false) continue;
|
|
74
|
+
|
|
75
|
+
if (!triggerMap.has(trigger.watch_path)) {
|
|
76
|
+
triggerMap.set(trigger.watch_path, []);
|
|
77
|
+
}
|
|
78
|
+
triggerMap.get(trigger.watch_path).push(trigger);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const activeCount = [...triggerMap.values()].reduce((sum, arr) => sum + arr.length, 0);
|
|
82
|
+
|
|
83
|
+
if (activeCount === 0) {
|
|
84
|
+
console.log('No active triggers');
|
|
85
|
+
} else {
|
|
86
|
+
for (const [watchPath, pathTriggers] of triggerMap) {
|
|
87
|
+
for (const t of pathTriggers) {
|
|
88
|
+
const actionTypes = t.actions.map(a => a.type || 'agent').join(', ');
|
|
89
|
+
console.log(` ${t.name}: ${watchPath} (${actionTypes})`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log('----------------\n');
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fire matching triggers for a given path (non-blocking)
|
|
98
|
+
* @param {string} path - Request path (e.g., '/webhook')
|
|
99
|
+
* @param {Object} body - Request body
|
|
100
|
+
* @param {Object} [query={}] - Query parameters
|
|
101
|
+
* @param {Object} [headers={}] - Request headers
|
|
102
|
+
*/
|
|
103
|
+
function fireTriggers(path, body, query = {}, headers = {}) {
|
|
104
|
+
const matched = triggerMap.get(path);
|
|
105
|
+
if (matched) {
|
|
106
|
+
const context = { body, query, headers };
|
|
107
|
+
for (const trigger of matched) {
|
|
108
|
+
executeActions(trigger, context).catch(err => {
|
|
109
|
+
console.error(`[TRIGGER] ${trigger.name}: unhandled error - ${err.message}`);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { triggerMap, fireTriggers };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { loadTriggers };
|