create-loopengine 0.1.53 → 0.1.54
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/package.json +1 -1
- package/template/adapters/http.ts +113 -0
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -107,6 +107,13 @@ import {
|
|
|
107
107
|
renderDecideInvalidPage,
|
|
108
108
|
describeAgentNotifier,
|
|
109
109
|
listAgentPending,
|
|
110
|
+
listAgentSlackChannels,
|
|
111
|
+
slackCredentialsFor,
|
|
112
|
+
openSlackAnswerModal,
|
|
113
|
+
parseSlackInteraction,
|
|
114
|
+
replyInSlack,
|
|
115
|
+
slackOutcomeText,
|
|
116
|
+
verifySlackRequest,
|
|
110
117
|
removeAgentNotifier,
|
|
111
118
|
saveAgentNotifier,
|
|
112
119
|
testAgentNotifier,
|
|
@@ -239,6 +246,15 @@ function resolveTenant(
|
|
|
239
246
|
return result === undefined ? { ok: false } : { ok: true, value: result }
|
|
240
247
|
}
|
|
241
248
|
|
|
249
|
+
function readRawBody(req: IncomingMessage): Promise<string> {
|
|
250
|
+
return new Promise((resolve, reject) => {
|
|
251
|
+
let data = ''
|
|
252
|
+
req.on('data', (chunk) => (data += chunk))
|
|
253
|
+
req.on('end', () => resolve(data))
|
|
254
|
+
req.on('error', reject)
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
242
258
|
function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
243
259
|
return new Promise((resolve, reject) => {
|
|
244
260
|
let data = ''
|
|
@@ -1109,6 +1125,23 @@ async function handleNotifierTest(res: ServerResponse, agentName: string): Promi
|
|
|
1109
1125
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook)))
|
|
1110
1126
|
}
|
|
1111
1127
|
|
|
1128
|
+
// Backs the console's Slack channel picker. A bot token typed into the form
|
|
1129
|
+
// (not saved yet) can be sent in the body; otherwise the saved one is used.
|
|
1130
|
+
async function handleSlackChannels(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1131
|
+
const entry = getEntry(agentName)
|
|
1132
|
+
if (!entry) {
|
|
1133
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1134
|
+
return
|
|
1135
|
+
}
|
|
1136
|
+
const body = await readJsonBody(req)
|
|
1137
|
+
try {
|
|
1138
|
+
const channels = await listAgentSlackChannels(entry.config, typeof body.botToken === 'string' ? body.botToken : undefined)
|
|
1139
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ channels }))
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1112
1145
|
// Every approval/question still waiting for this agent — for an app that
|
|
1113
1146
|
// can't receive webhooks (it polls this instead) and for the console.
|
|
1114
1147
|
async function handlePendingGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
@@ -2489,7 +2522,82 @@ async function handleDecide(req: IncomingMessage, res: ServerResponse, rawToken:
|
|
|
2489
2522
|
else await resolvePendingApproval(res, token.pendingId, { decision: body.decision, editedArgs: body.editedArgs })
|
|
2490
2523
|
}
|
|
2491
2524
|
|
|
2525
|
+
// Runs one of the resolve/answer handlers above without a real HTTP
|
|
2526
|
+
// response to write to — for the Slack endpoint below, which has already
|
|
2527
|
+
// answered Slack and reports the outcome back into the Slack message.
|
|
2528
|
+
function captureResponse(): { res: ServerResponse; done: Promise<{ status: number; body: Record<string, unknown> }> } {
|
|
2529
|
+
let resolveDone: (value: { status: number; body: Record<string, unknown> }) => void
|
|
2530
|
+
const done = new Promise<{ status: number; body: Record<string, unknown> }>((resolve) => (resolveDone = resolve))
|
|
2531
|
+
let status = 200
|
|
2532
|
+
const res = {
|
|
2533
|
+
headersSent: false,
|
|
2534
|
+
writeHead(code: number) {
|
|
2535
|
+
status = code
|
|
2536
|
+
return res
|
|
2537
|
+
},
|
|
2538
|
+
end(chunk?: string) {
|
|
2539
|
+
let body: Record<string, unknown> = {}
|
|
2540
|
+
try {
|
|
2541
|
+
body = chunk ? (JSON.parse(chunk) as Record<string, unknown>) : {}
|
|
2542
|
+
} catch {}
|
|
2543
|
+
resolveDone({ status, body })
|
|
2544
|
+
return res
|
|
2545
|
+
},
|
|
2546
|
+
}
|
|
2547
|
+
return { res: res as unknown as ServerResponse, done }
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// A Slack app's Interactivity Request URL (core/slack-interactions.ts):
|
|
2551
|
+
// Approve/Deny, an answer option, or the "Other…" modal, from a Slack
|
|
2552
|
+
// notifier's message. Outside the admin login on purpose — Slack can't
|
|
2553
|
+
// send it — and verified with that agent's Slack signing secret instead.
|
|
2554
|
+
// Slack wants an answer within three seconds, so the decision is acked
|
|
2555
|
+
// right away and its outcome posted back into the message afterwards.
|
|
2556
|
+
async function handleSlackInteraction(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
2557
|
+
const rawBody = await readRawBody(req)
|
|
2558
|
+
const entry = getEntry(agentName)
|
|
2559
|
+
const { botToken, signingSecret } = entry ? slackCredentialsFor(entry.config) : {}
|
|
2560
|
+
if (!entry || !signingSecret) {
|
|
2561
|
+
res.writeHead(404, { 'content-type': 'text/plain' }).end('No Slack signing secret is set for this agent.')
|
|
2562
|
+
return
|
|
2563
|
+
}
|
|
2564
|
+
const timestamp = req.headers['x-slack-request-timestamp']
|
|
2565
|
+
const signature = req.headers['x-slack-signature']
|
|
2566
|
+
if (!verifySlackRequest(rawBody, typeof timestamp === 'string' ? timestamp : undefined, typeof signature === 'string' ? signature : undefined, signingSecret)) {
|
|
2567
|
+
res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid Slack signature.')
|
|
2568
|
+
return
|
|
2569
|
+
}
|
|
2570
|
+
const interaction = parseSlackInteraction(rawBody)
|
|
2571
|
+
if (interaction.type === 'open_answer') {
|
|
2572
|
+
if (botToken) await openSlackAnswerModal(botToken, interaction.triggerId, interaction.pendingId).catch((err) => console.error('[loopengine] Slack modal failed:', err))
|
|
2573
|
+
res.writeHead(200).end()
|
|
2574
|
+
return
|
|
2575
|
+
}
|
|
2576
|
+
res.writeHead(200).end()
|
|
2577
|
+
if (interaction.type === 'ignore') return
|
|
2578
|
+
|
|
2579
|
+
const capture = captureResponse()
|
|
2580
|
+
if (interaction.type === 'decide') await resolvePendingApproval(capture.res, interaction.pendingId, { decision: interaction.decision })
|
|
2581
|
+
else await answerPendingQuestion(capture.res, interaction.pendingId, { answer: interaction.answer })
|
|
2582
|
+
const outcome = await capture.done
|
|
2583
|
+
if (interaction.responseUrl) {
|
|
2584
|
+
const text = slackOutcomeText(interaction.type === 'decide' ? interaction.decision : 'answer', outcome.status, outcome.body, interaction.user)
|
|
2585
|
+
await replyInSlack(interaction.responseUrl, text).catch((err) => console.error('[loopengine] Slack reply failed:', err))
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2492
2589
|
const server = createServer(async (req, res) => {
|
|
2590
|
+
const slackHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/slack\/([^/]+)$/)
|
|
2591
|
+
if (slackHookMatch) {
|
|
2592
|
+
try {
|
|
2593
|
+
await handleSlackInteraction(req, res, decodeURIComponent(slackHookMatch[1]))
|
|
2594
|
+
} catch (err) {
|
|
2595
|
+
console.error('[loopengine] Slack interaction failed:', err)
|
|
2596
|
+
if (!res.headersSent) res.writeHead(500).end()
|
|
2597
|
+
}
|
|
2598
|
+
return
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2493
2601
|
const decideMatch = (req.method === 'GET' || req.method === 'POST') && (req.url ?? '/').split('?')[0].match(/^\/decide\/([A-Za-z0-9_.-]+)$/)
|
|
2494
2602
|
if (decideMatch) {
|
|
2495
2603
|
try {
|
|
@@ -2896,6 +3004,11 @@ const server = createServer(async (req, res) => {
|
|
|
2896
3004
|
await handleNotifierTest(res, decodeURIComponent(notifierTestMatch[1]))
|
|
2897
3005
|
return
|
|
2898
3006
|
}
|
|
3007
|
+
const slackChannelsMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-channels$/)
|
|
3008
|
+
if (slackChannelsMatch) {
|
|
3009
|
+
await handleSlackChannels(req, res, decodeURIComponent(slackChannelsMatch[1]))
|
|
3010
|
+
return
|
|
3011
|
+
}
|
|
2899
3012
|
const pendingListMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/pending$/)
|
|
2900
3013
|
if (pendingListMatch) {
|
|
2901
3014
|
await handlePendingGet(res, decodeURIComponent(pendingListMatch[1]))
|