create-loopengine 0.3.0 → 0.3.1

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.3.0",
3
+ "version": "0.3.1",
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",
@@ -122,6 +122,7 @@ import {
122
122
  TRUSTED_PROXY_IDENTITY_HEADER,
123
123
  NotifierFileError,
124
124
  verifyDecisionToken,
125
+ decidePath,
125
126
  renderDecidePage,
126
127
  renderDecideGonePage,
127
128
  renderDecideInvalidPage,
@@ -145,7 +146,16 @@ import {
145
146
  listAgentPending,
146
147
  listAgentSlackChannels,
147
148
  slackCredentialsFor,
148
- slackConversationsOn,
149
+ agentTelegramWebhookInfo,
150
+ clickAllowed,
151
+ connectionPublicUrl,
152
+ conversationsOn,
153
+ jobAllowed,
154
+ jobsOn,
155
+ listAgentTelegramPeople,
156
+ setupAgentTelegramWebhook,
157
+ telegramCredentialsFor,
158
+ type TwoWayApp,
149
159
  slackClickAllowed,
150
160
  agentApprovers,
151
161
  setAgentApprovers,
@@ -167,6 +177,28 @@ import {
167
177
  firstSighting,
168
178
  handleInboundMessage,
169
179
  createSlackConnector,
180
+ outcomeText,
181
+ handleJobMessage,
182
+ isJobMessage,
183
+ settleJobThread,
184
+ type JobDeps,
185
+ type JobTurn,
186
+ findJobThreadBySession,
187
+ findThread,
188
+ type InboundMessage,
189
+ type MessagingConnector,
190
+ formatArgs,
191
+ answerTelegramClick,
192
+ createTelegramConnector,
193
+ editTelegramMessage,
194
+ parseTelegramUpdate,
195
+ readTelegramThread,
196
+ recordTelegramMessage,
197
+ telegramBotUsername,
198
+ telegramEscape,
199
+ telegramRootOf,
200
+ verifyTelegramRequest,
201
+ type TelegramUpdate,
170
202
  parseSlackEvent,
171
203
  removeAgentNotifier,
172
204
  saveAgentNotifier,
@@ -276,6 +308,7 @@ import {
276
308
  createScheduleTools,
277
309
  type PendingQuestion,
278
310
  WebhookNotifier,
311
+ publicBaseUrl,
279
312
  // Workflows (skills with a start message), their runs, run callbacks
280
313
  // and webhook triggers.
281
314
  WorkflowParamsError,
@@ -1478,6 +1511,11 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
1478
1511
  return entry ? describeAgentNotifier(entry.config, defaultWebhook).source !== 'none' : false
1479
1512
  },
1480
1513
  })
1514
+ // The planned webhook's URL, shown once on the done screen.
1515
+ if (created.webhook) {
1516
+ const { token, ...webhook } = created.webhook
1517
+ return json(200, { ...created, webhook: { ...webhook, url: `${requestBaseUrl(req)}/hooks/workflows/${token}` } })
1518
+ }
1481
1519
  json(200, created)
1482
1520
  // New tools in an agent that was already running load with a restart,
1483
1521
  // as after installing an ability from its tab.
@@ -2134,7 +2172,7 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
2134
2172
  }
2135
2173
  const body = await readJsonBody(req)
2136
2174
  try {
2137
- const approvers = setAgentApprovers(entry.config, body.approvers)
2175
+ const approvers = setAgentApprovers(entry.config, body.approvers, body.app === 'telegram' ? 'telegram' : 'slack')
2138
2176
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvers }))
2139
2177
  } catch (err) {
2140
2178
  const status = err instanceof NotifierFileError ? 400 : 500
@@ -2142,6 +2180,26 @@ async function handleApproversPut(req: IncomingMessage, res: ServerResponse, age
2142
2180
  }
2143
2181
  }
2144
2182
 
2183
+ // Messaging tab → Telegram: where the bot's updates go (GET), and
2184
+ // pointing them at this server (POST), which turns on buttons and
2185
+ // replies. Setting it needs the admin login, like saving a secret.
2186
+ async function handleTelegramWebhookSetup(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
2187
+ const json = (status: number, body: unknown): void => {
2188
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
2189
+ }
2190
+ const entry = getEntry(agentName)
2191
+ if (!entry) return json(404, { error: `unknown agent '${agentName}'` })
2192
+ try {
2193
+ if (req.method === 'GET') return json(200, await agentTelegramWebhookInfo(entry.config))
2194
+ if (!secretsProtected) return json(403, { error: 'Setting up the Telegram webhook needs LOOPENGINE_ADMIN_AUTH set on the server.' })
2195
+ const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
2196
+ const base = `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
2197
+ return json(200, await setupAgentTelegramWebhook(entry.config, base))
2198
+ } catch (err) {
2199
+ return json(400, { error: err instanceof Error ? err.message : String(err) })
2200
+ }
2201
+ }
2202
+
2145
2203
  async function handleSlackMembers(res: ServerResponse, agentName: string): Promise<void> {
2146
2204
  const entry = getEntry(agentName)
2147
2205
  if (!entry) {
@@ -3362,6 +3420,7 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
3362
3420
  }
3363
3421
  const environment = process.env.LOOPENGINE_ENV ?? 'production'
3364
3422
  const storageSessionId = `${finalCheckpoint.tenant}:${environment}:${finalCheckpoint.agent}:${finalCheckpoint.sessionId}`
3423
+ const fromJobThread = Boolean(findJobThreadBySession(agentDirFor(entry.config), finalCheckpoint.sessionId))
3365
3424
 
3366
3425
  const responseBody = await sessions.withSession(storageSessionId, async (history) => {
3367
3426
  // Same channel/approver/questionHandler fallback the original
@@ -3389,8 +3448,9 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
3389
3448
  tenant: finalCheckpoint.tenant,
3390
3449
  sessionId: finalCheckpoint.sessionId,
3391
3450
  channel: 'http',
3392
- approver: defaultHttpWebhookNotifier ?? createTrackedApprover(finalCheckpoint.sessionId),
3393
- questionHandler: defaultHttpWebhookNotifier,
3451
+ // A job thread's run keeps asking in its thread (runJobTurn).
3452
+ approver: fromJobThread ? threadApprover : defaultHttpWebhookNotifier ?? createTrackedApprover(finalCheckpoint.sessionId),
3453
+ questionHandler: fromJobThread ? threadQuestions : defaultHttpWebhookNotifier,
3394
3454
  onQuestionPending: () => {},
3395
3455
  })
3396
3456
  // The resumed turn can itself hit another durably-pending call/
@@ -3408,6 +3468,7 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
3408
3468
  const resumed = { text: responseBody.text, pendingIds: responseBody.pending?.map((p) => p.pendingId) }
3409
3469
  void scheduler.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle scheduled run:', err))
3410
3470
  void workflowRunner.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle workflow run:', err))
3471
+ if (fromJobThread) void settleJobFromResume(finalCheckpoint.agent, finalCheckpoint.sessionId, responseBody.text, responseBody.pending).catch((err) => console.error('[loopengine] could not answer in the job thread:', err))
3411
3472
  res.writeHead(200, { 'content-type': 'application/json' }).end(
3412
3473
  JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
3413
3474
  )
@@ -3782,24 +3843,217 @@ async function handleSlackEvents(req: IncomingMessage, res: ServerResponse, agen
3782
3843
  }
3783
3844
  // Slack wants an answer within three seconds; the reply is posted after.
3784
3845
  res.writeHead(200).end()
3785
- if (event.type !== 'message' || !firstSighting(event) || !slackConversationsOn(entry.config)) return
3786
- await handleInboundMessage(event, slackMessagingDeps(agentName, entry, botToken))
3846
+ if (event.type !== 'message') return
3847
+ await routeInboundMessage(agentName, entry, event, createSlackConnector(botToken))
3848
+ }
3849
+
3850
+ // Where a person's message goes (core/messaging/): a reply under a
3851
+ // request still waiting goes to the request's thread conversation; a DM,
3852
+ // a mention, or a message in a job's thread runs the agent
3853
+ // (core/messaging/jobs.ts). Anything else is left alone. Checked before
3854
+ // the message counts as seen: Slack sends a mention twice (as a message
3855
+ // and as an app_mention), and only the app_mention starts a job.
3856
+ async function routeInboundMessage(agentName: string, entry: RegistryEntry, message: InboundMessage, connector: MessagingConnector): Promise<void> {
3857
+ const app = connector.app as TwoWayApp
3858
+ const dir = agentDirFor(entry.config)
3859
+ const jobs = jobsOn(entry.config, app) && isJobMessage(dir, message)
3860
+ let target: 'request' | 'job' | undefined = jobs ? 'job' : undefined
3861
+ if (message.threadTs) {
3862
+ const link = findThread(dir, app, message.conversation, message.threadTs)
3863
+ // A request already decided in a job's thread: the job carries on.
3864
+ if (link && (!jobs || (await checkpoints.get(link.pendingId)))) target = conversationsOn(entry.config, app) ? 'request' : undefined
3865
+ }
3866
+ if (!target || !firstSighting(message)) return
3867
+ if (target === 'request') await handleInboundMessage(message, messagingDeps(agentName, entry, connector))
3868
+ else await handleJobMessage(message, jobDeps(agentName, entry, connector))
3869
+ }
3870
+
3871
+ // A Telegram bot's webhook (core/messaging/connectors/telegram/), set up
3872
+ // from the Messaging tab: people's messages (replies to a request, and
3873
+ // DMs and mentions that start jobs) and clicks on the agent's buttons.
3874
+ // Outside the admin login like Slack's endpoints, and checked against the
3875
+ // secret Telegram was given when the webhook was set. Telegram resends an
3876
+ // update that isn't answered 200 promptly, so it's answered first.
3877
+ async function handleTelegramWebhook(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
3878
+ const rawBody = await readRawBody(req)
3879
+ const entry = getEntry(agentName)
3880
+ const { botToken, webhookSecret } = entry ? telegramCredentialsFor(entry.config) : {}
3881
+ if (!entry || !botToken || !webhookSecret) {
3882
+ res.writeHead(404, { 'content-type': 'text/plain' }).end('No Telegram webhook is set up for this agent.')
3883
+ return
3884
+ }
3885
+ const header = req.headers['x-telegram-bot-api-secret-token']
3886
+ if (!verifyTelegramRequest(typeof header === 'string' ? header : undefined, webhookSecret)) {
3887
+ res.writeHead(401, { 'content-type': 'text/plain' }).end('Invalid Telegram secret token.')
3888
+ return
3889
+ }
3890
+ res.writeHead(200).end()
3891
+ const dir = agentDirFor(entry.config)
3892
+ const update = parseTelegramUpdate(rawBody, { botUsername: await telegramBotUsername(botToken), rootOf: (chat, id) => telegramRootOf(dir, chat, id) })
3893
+ const connector = createTelegramConnector(botToken, dir)
3894
+ if (update.type === 'message') {
3895
+ // Kept for the thread's context and the approver picker: Telegram
3896
+ // can't be asked for either later.
3897
+ recordTelegramMessage(dir, {
3898
+ chat: update.conversation, id: update.ts, root: update.threadTs ?? update.ts, user: update.user, fromBot: false, text: update.text,
3899
+ ...(update.userName ? { name: update.userName } : {}), ...(update.username ? { username: update.username } : {}),
3900
+ })
3901
+ await routeInboundMessage(agentName, entry, update, connector)
3902
+ } else if (update.type === 'click') {
3903
+ await handleTelegramClick(agentName, entry, botToken, connector, update)
3904
+ }
3905
+ }
3906
+
3907
+ // A click on one of the agent's Telegram buttons: a request's Approve or
3908
+ // Deny, a question's option, or a button under a thread reply (Approve as
3909
+ // changed, Keep original, Confirm, Cancel). The message the button was on
3910
+ // is edited to say what happened, which also takes its buttons away.
3911
+ async function handleTelegramClick(agentName: string, entry: RegistryEntry, botToken: string, connector: MessagingConnector, click: Extract<TelegramUpdate, { type: 'click' }>): Promise<void> {
3912
+ // Once the agent has Telegram approvers, only their clicks count.
3913
+ const gate = clickAllowed(entry.config, 'telegram', click.user)
3914
+ if (!gate.allowed) {
3915
+ await answerTelegramClick(botToken, click.callbackId, gate.message)
3916
+ return
3917
+ }
3918
+ await answerTelegramClick(botToken, click.callbackId)
3919
+ const deps = messagingDeps(agentName, entry, connector)
3920
+ const who = click.username ? `@${click.username}` : click.userName ?? 'someone'
3921
+ if (click.button.type === 'action') {
3922
+ const text = await handleMessagingAction(click.button.action, click.pendingId, click.user, deps)
3923
+ await editTelegramMessage(botToken, click.conversation, click.messageId, text)
3924
+ return
3925
+ }
3926
+ const item = (await checkpoints.get(click.pendingId))?.outstanding[click.pendingId]
3927
+ let decision: MessagingDecision
3928
+ let answer: string | undefined
3929
+ if (click.button.type === 'decide') {
3930
+ decision = { kind: click.button.decision }
3931
+ } else {
3932
+ const options = item?.args.options
3933
+ answer = Array.isArray(options) && typeof options[click.button.index] === 'string' ? (options[click.button.index] as string) : undefined
3934
+ // Gone from the checkpoint: decided already, or an option list that changed.
3935
+ decision = answer === undefined ? { kind: 'deny' } : { kind: 'answer', answer }
3936
+ if (answer === undefined) {
3937
+ await editTelegramMessage(botToken, click.conversation, click.messageId, item ? 'That option is no longer offered.' : 'This was already decided elsewhere.')
3938
+ return
3939
+ }
3940
+ }
3941
+ const outcome = await decideFromMessaging(click.pendingId, decision)
3942
+ const kind = decision.kind === 'answer' ? 'answer' : decision.kind
3943
+ if (!outcome.error && !outcome.alreadyResolved) {
3944
+ const done = kind === 'approve' ? 'Approved' : kind === 'deny' ? 'Denied' : 'Answered'
3945
+ await settleThreadChoice(deps, click.pendingId, `${done} by ${who} with the request's own buttons.`).catch((err) => console.error('[loopengine] Telegram update failed:', err))
3946
+ }
3947
+ // The request as it was, with what happened under it.
3948
+ const settled = !outcome.error && !outcome.alreadyResolved
3949
+ let result = outcomeText(kind, who, outcome, answer === undefined ? undefined : telegramEscape(answer))
3950
+ if (kind === 'approve' && settled) result = `**Approved by ${who}.** ${result}`
3951
+ const request = readTelegramThread(agentDirFor(entry.config), click.conversation, click.messageId).find((m) => m.ts === click.messageId)?.text
3952
+ if (request) result = `${request}\n\n${result}`
3953
+ else if (item && kind === 'approve' && settled) result += `\nRan \`${item.tool}\` with:\n${formatArgs(item.args, telegramEscape, (await resolveAgentTool(entry.config, item.tool))?.input_schema)}`
3954
+ await editTelegramMessage(botToken, click.conversation, click.messageId, result)
3955
+ }
3956
+
3957
+ // A job someone started by messaging the agent (core/messaging/jobs.ts).
3958
+ function jobDeps(agentName: string, entry: RegistryEntry, connector: MessagingConnector): JobDeps {
3959
+ const app = connector.app as TwoWayApp
3960
+ return {
3961
+ agentName,
3962
+ agentDir: agentDirFor(entry.config),
3963
+ connector,
3964
+ plan: process.env.LOOPENGINE_PLAN,
3965
+ allowed: (message) => jobAllowed(entry.config, app, message),
3966
+ runTurn: (sessionId, text) => runJobTurn(agentName, sessionId, text),
3967
+ decideLink: (pendingId, kind) => {
3968
+ const base = publicBaseUrl(connectionPublicUrl(entry.config, app))
3969
+ return base ? `${base}${decidePath(pendingId, kind)}` : undefined
3970
+ },
3971
+ }
3972
+ }
3973
+
3974
+ // A job thread's approvals and questions wait in the thread itself
3975
+ // (core/messaging/jobs.ts posts them), so the run's own approver only
3976
+ // hands out an id. The agent's notifier, when it has one, still sends its
3977
+ // request too, and wins: run-agent prefers it on the http channel.
3978
+ const threadApprover = {
3979
+ requestApproval: async () => false,
3980
+ requestDurableApproval: () => ({ pendingId: randomUUID() }),
3981
+ }
3982
+ const threadQuestions = {
3983
+ requestQuestion: async () => NO_HUMAN_ANSWER,
3984
+ notifyPendingQuestion: () => ({ pendingId: randomUUID() }),
3985
+ }
3986
+
3987
+ function jobTurnOf(text: string, pending: PendingSummary[] | undefined): JobTurn {
3988
+ if (!pending?.length) return { kind: 'done', text }
3989
+ return {
3990
+ kind: 'pending',
3991
+ items: pending.map((p) => ({
3992
+ pendingId: p.pendingId,
3993
+ kind: p.kind,
3994
+ tool: p.tool,
3995
+ ...(p.kind === 'question' ? { question: typeof p.args.question === 'string' ? p.args.question : p.reason } : {}),
3996
+ })),
3997
+ }
3998
+ }
3999
+
4000
+ // One turn of a job thread: the same session for the whole thread.
4001
+ async function runJobTurn(agent: string, rawSessionId: string, message: string): Promise<JobTurn> {
4002
+ const entry = getEntry(agent)
4003
+ if (!entry) throw new Error(`no agent named "${agent}" in this project`)
4004
+ const tenant = 'default'
4005
+ const environment = process.env.LOOPENGINE_ENV ?? 'production'
4006
+ const outcome = await sessions.withSession(`${tenant}:${environment}:${agent}:${rawSessionId}`, async (history) => {
4007
+ const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
4008
+ tenant,
4009
+ sessionId: rawSessionId,
4010
+ channel: 'http',
4011
+ approver: threadApprover,
4012
+ questionHandler: threadQuestions,
4013
+ })
4014
+ if (result.pending) await createCheckpointFromPending(agent, tenant, rawSessionId, result.pending)
4015
+ return { newMessages: result.newMessages, result: { text: result.text, pending: pendingSummary(result.pending) } }
4016
+ })
4017
+ return jobTurnOf(outcome.text, outcome.pending)
4018
+ }
4019
+
4020
+ // A resumed run that came from a job thread answers there.
4021
+ async function settleJobFromResume(agent: string, sessionId: string, text: string, pending: PendingSummary[] | undefined): Promise<void> {
4022
+ const entry = getEntry(agent)
4023
+ if (!entry) return
4024
+ const job = findJobThreadBySession(agentDirFor(entry.config), sessionId)
4025
+ if (!job) return
4026
+ let connector: MessagingConnector | undefined
4027
+ if (job.app === 'slack') {
4028
+ const { botToken } = slackCredentialsFor(entry.config)
4029
+ if (botToken) connector = createSlackConnector(botToken)
4030
+ } else if (job.app === 'telegram') {
4031
+ const { botToken } = telegramCredentialsFor(entry.config)
4032
+ if (botToken) connector = createTelegramConnector(botToken, agentDirFor(entry.config))
4033
+ }
4034
+ if (!connector) return
4035
+ await settleJobThread(jobDeps(agent, entry, connector), sessionId, jobTurnOf(text, pending))
3787
4036
  }
3788
4037
 
3789
4038
  // What core/messaging/ needs from this server to answer, decide and
3790
4039
  // change an agent's Slack requests.
3791
4040
  function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: string): MessagingDeps {
4041
+ return messagingDeps(agentName, entry, createSlackConnector(botToken))
4042
+ }
4043
+
4044
+ // The same, for any app's connector.
4045
+ function messagingDeps(agentName: string, entry: RegistryEntry, connector: MessagingConnector): MessagingDeps {
3792
4046
  const environment = process.env.LOOPENGINE_ENV ?? 'production'
3793
4047
  return {
3794
4048
  agentName,
3795
4049
  agentDir: agentDirFor(entry.config),
3796
4050
  systemPrompt: entry.config.systemPrompt,
3797
- connector: createSlackConnector(botToken),
4051
+ connector,
3798
4052
  createModelCall: () => entry.createModelCall(),
3799
4053
  getCheckpoint: (pendingId) => checkpoints.get(pendingId),
3800
4054
  getHistory: (checkpoint) => sessions.getHistory(`${checkpoint.tenant}:${environment}:${checkpoint.agent}:${checkpoint.sessionId}`),
3801
4055
  plan: process.env.LOOPENGINE_PLAN,
3802
- approvers: () => agentApprovers(entry.config),
4056
+ approvers: () => agentApprovers(entry.config, connector.app as TwoWayApp),
3803
4057
  decide: decideFromMessaging,
3804
4058
  toolSchema: async (toolName) => (await resolveAgentTool(entry.config, toolName))?.input_schema,
3805
4059
  // The same checks the console's approve-with-edits applies, plus the
@@ -3823,6 +4077,10 @@ function slackMessagingDeps(agentName: string, entry: RegistryEntry, botToken: s
3823
4077
  // recorded through the same resolve and answer handlers as the console's
3824
4078
  // buttons.
3825
4079
  async function decideFromMessaging(pendingId: string, decision: MessagingDecision): Promise<DecisionOutcome> {
4080
+ // A run from a job thread posts its own result there once it resumes
4081
+ // (settleJobFromResume), so it isn't quoted here as well.
4082
+ const checkpoint = await checkpoints.get(pendingId)
4083
+ const fromJob = checkpoint ? Boolean(getEntry(checkpoint.agent) && findJobThreadBySession(agentDir(checkpoint.agent), checkpoint.sessionId)) : false
3826
4084
  const capture = captureResponse()
3827
4085
  if (decision.kind === 'answer') await answerPendingQuestion(capture.res, pendingId, { answer: decision.answer })
3828
4086
  else await resolvePendingApproval(capture.res, pendingId, { decision: decision.kind, ...(decision.kind === 'approve' && decision.editedArgs ? { editedArgs: decision.editedArgs } : {}) })
@@ -3831,7 +4089,7 @@ async function decideFromMessaging(pendingId: string, decision: MessagingDecisio
3831
4089
  if (body.alreadyResolved) return { alreadyResolved: true }
3832
4090
  if (body.resolved) return { stillWaiting: true }
3833
4091
  if (Array.isArray(body.pending) && body.pending.length) return { morePending: true }
3834
- return { text: typeof body.text === 'string' ? body.text : undefined }
4092
+ return { text: typeof body.text === 'string' && !fromJob ? body.text : undefined }
3835
4093
  }
3836
4094
 
3837
4095
  // ---- Webhook triggers (core/workflow-triggers.ts): an outside service
@@ -3892,6 +4150,13 @@ async function handleWorkflowHook(req: IncomingMessage, res: ServerResponse, tok
3892
4150
  }
3893
4151
  }
3894
4152
 
4153
+ // This server's address as the request reached it (behind a proxy, as
4154
+ // the proxy says), for URLs shown in the console.
4155
+ function requestBaseUrl(req: IncomingMessage): string {
4156
+ const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
4157
+ return `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
4158
+ }
4159
+
3895
4160
  // A workflow's webhook triggers, for the console: list, make (its URL is
3896
4161
  // shown once), change, rotate (a new URL), delete.
3897
4162
  async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse, agentName: string, workflowId: string, triggerId: string | undefined, action: string | undefined): Promise<void> {
@@ -3901,9 +4166,7 @@ async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse,
3901
4166
  if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
3902
4167
  const workflow = readWorkflow(agentDir(agentName), workflowId)
3903
4168
  if (!workflow) return json(404, { error: `no workflow ${workflowId} in ${agentName}` })
3904
- const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
3905
- const base = `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
3906
- const urlFor = (token: string) => `${base}/hooks/workflows/${token}`
4169
+ const urlFor = (token: string) => `${requestBaseUrl(req)}/hooks/workflows/${token}`
3907
4170
  try {
3908
4171
  if (!triggerId && req.method === 'GET') return json(200, { triggers: (await listTriggers(agentName, workflowId)).map(publicTrigger) })
3909
4172
  if (!triggerId && req.method === 'POST') {
@@ -3966,6 +4229,18 @@ const server = createServer(async (req, res) => {
3966
4229
  return
3967
4230
  }
3968
4231
 
4232
+ const telegramHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/telegram\/([^/]+)$/)
4233
+ if (telegramHookMatch) {
4234
+ try {
4235
+ lastRequestAt = Date.now()
4236
+ await tracked(() => handleTelegramWebhook(req, res, decodeURIComponent(telegramHookMatch[1])))
4237
+ } catch (err) {
4238
+ console.error('[loopengine] Telegram update failed:', err)
4239
+ if (!res.headersSent) res.writeHead(500).end()
4240
+ }
4241
+ return
4242
+ }
4243
+
3969
4244
  // A workflow's webhook trigger: its token is the credential (no login).
3970
4245
  const workflowHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/workflows\/([A-Za-z0-9_-]+)$/)
3971
4246
  if (workflowHookMatch) {
@@ -4613,6 +4888,18 @@ const server = createServer(async (req, res) => {
4613
4888
  await handleApproversPut(req, res, decodeURIComponent(approversMatch[1]))
4614
4889
  return
4615
4890
  }
4891
+ const telegramPeopleMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/telegram-people$/)
4892
+ if (telegramPeopleMatch) {
4893
+ const entry = getEntry(decodeURIComponent(telegramPeopleMatch[1]))
4894
+ if (!entry) res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'unknown agent' }))
4895
+ else res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ people: listAgentTelegramPeople(entry.config) }))
4896
+ return
4897
+ }
4898
+ const telegramWebhookMatch = (req.method === 'GET' || req.method === 'POST') && pathname.match(/^\/agents\/([^/]+)\/notifier\/telegram-webhook$/)
4899
+ if (telegramWebhookMatch) {
4900
+ await handleTelegramWebhookSetup(req, res, decodeURIComponent(telegramWebhookMatch[1]))
4901
+ return
4902
+ }
4616
4903
  const slackMembersMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/notifier\/slack-members$/)
4617
4904
  if (slackMembersMatch) {
4618
4905
  await handleSlackMembers(res, decodeURIComponent(slackMembersMatch[1]))
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.3.0",
12
+ "loopengine": "^0.3.3",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"