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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.53",
3
+ "version": "0.1.54",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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]))
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.68",
12
+ "loopengine": "^0.1.69",
13
13
  "actauth": "^0.0.12"
14
14
  },
15
15
  "devDependencies": {