loopengine 0.1.68 → 0.1.69
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/README.md +8 -3
- package/dist/core/agent-config.d.ts +2 -0
- package/dist/core/http-notifier.js +2 -2
- package/dist/core/http-notify-triggers/lark.d.ts +9 -0
- package/dist/core/http-notify-triggers/lark.js +39 -19
- package/dist/core/notifier-file.d.ts +4 -0
- package/dist/core/notifier-file.js +17 -4
- package/dist/core/slack-interactions.d.ts +59 -0
- package/dist/core/slack-interactions.js +168 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/web/agents-config-page.js +70 -3
- package/dist/web/notifier-admin.d.ts +18 -0
- package/dist/web/notifier-admin.js +31 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -182,9 +182,14 @@ waiting with Approve/Deny buttons. The tab warns you when an agent has
|
|
|
182
182
|
Every notification links to a **decision page**: a signed, single-use
|
|
183
183
|
link (`/decide/<token>`, valid 7 days) where whoever got the message can
|
|
184
184
|
approve, deny, edit the arguments or answer a question, with no login and
|
|
185
|
-
no app of your own. So the quickest setup is Slack:
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
no app of your own. So the quickest setup is Slack: create the Slack app
|
|
186
|
+
from the manifest the console shows, paste its bot token, pick a channel
|
|
187
|
+
from the list, save; each approval arrives with an "Open to approve or
|
|
188
|
+
deny" link. Add the app's signing secret too and the message also gets
|
|
189
|
+
Approve/Deny buttons, handled by LoopEngine itself at
|
|
190
|
+
`/hooks/slack/<agent>`, with no handler to deploy. Lark cards carry the
|
|
191
|
+
same link. Links and buttons need the server to be reachable from where
|
|
192
|
+
people click (a public HTTPS address or a tunnel).
|
|
188
193
|
|
|
189
194
|
For a third-party app calling an agent over plain HTTP:
|
|
190
195
|
|
|
@@ -114,8 +114,8 @@ function buildResolved(httpNotifier) {
|
|
|
114
114
|
break;
|
|
115
115
|
}
|
|
116
116
|
case 'lark': {
|
|
117
|
-
const { appId, appSecret, chatId } = httpNotifier.config;
|
|
118
|
-
const lark = new LarkNotifier({ appId, appSecret, chatId });
|
|
117
|
+
const { appId, appSecret, chatId, publicUrl, buttons } = httpNotifier.config;
|
|
118
|
+
const lark = new LarkNotifier({ appId, appSecret, chatId, publicUrl, buttons });
|
|
119
119
|
resolved = {
|
|
120
120
|
approver: events.has('approval') ? lark : undefined,
|
|
121
121
|
questionHandler: events.has('question') ? lark : undefined,
|
|
@@ -9,11 +9,20 @@ export declare class LarkNotifier implements DurableApprover, DurableQuestionHan
|
|
|
9
9
|
private readonly appId;
|
|
10
10
|
private readonly appSecret;
|
|
11
11
|
private readonly chatId;
|
|
12
|
+
private readonly publicUrl?;
|
|
13
|
+
private readonly buttons;
|
|
14
|
+
/** `publicUrl` / `buttons`: same meaning as SlackNotifier's — a decision
|
|
15
|
+
* link in each card when the server's address is known, and whether to
|
|
16
|
+
* add card buttons, which only work with a card-callback handler
|
|
17
|
+
* deployed (examples/notifier-handler/lark.ts). */
|
|
12
18
|
constructor(options: {
|
|
13
19
|
appId: string;
|
|
14
20
|
appSecret: string;
|
|
15
21
|
chatId: string;
|
|
22
|
+
publicUrl?: string;
|
|
23
|
+
buttons?: boolean;
|
|
16
24
|
});
|
|
25
|
+
private decideLine;
|
|
17
26
|
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
18
27
|
pendingId: string;
|
|
19
28
|
};
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
// sending and receiving ever run in different processes), deliberately
|
|
39
39
|
// not the approach used here.
|
|
40
40
|
import { randomUUID } from 'node:crypto';
|
|
41
|
+
import { decidePath } from '../approval-links.js';
|
|
42
|
+
import { publicBaseUrl } from './webhook.js';
|
|
41
43
|
/** Fixed id for the question form card's own input field — used both
|
|
42
44
|
* when *building* the card (notifyPendingQuestion below) and when
|
|
43
45
|
* *reading* the human's answer back out of the callback payload (the
|
|
@@ -62,10 +64,22 @@ export class LarkNotifier {
|
|
|
62
64
|
appId;
|
|
63
65
|
appSecret;
|
|
64
66
|
chatId;
|
|
67
|
+
publicUrl;
|
|
68
|
+
buttons;
|
|
69
|
+
/** `publicUrl` / `buttons`: same meaning as SlackNotifier's — a decision
|
|
70
|
+
* link in each card when the server's address is known, and whether to
|
|
71
|
+
* add card buttons, which only work with a card-callback handler
|
|
72
|
+
* deployed (examples/notifier-handler/lark.ts). */
|
|
65
73
|
constructor(options) {
|
|
66
74
|
this.appId = options.appId;
|
|
67
75
|
this.appSecret = options.appSecret;
|
|
68
76
|
this.chatId = options.chatId;
|
|
77
|
+
this.publicUrl = options.publicUrl;
|
|
78
|
+
this.buttons = options.buttons ?? true;
|
|
79
|
+
}
|
|
80
|
+
decideLine(pendingId, kind) {
|
|
81
|
+
const base = publicBaseUrl(this.publicUrl);
|
|
82
|
+
return base ? `\n[${kind === 'approval' ? 'Open to approve or deny' : 'Open to answer'}](${base}${decidePath(pendingId, kind)})` : '';
|
|
69
83
|
}
|
|
70
84
|
requestDurableApproval(tool, args, scope, reason) {
|
|
71
85
|
const pendingId = randomUUID();
|
|
@@ -80,16 +94,18 @@ export class LarkNotifier {
|
|
|
80
94
|
tag: 'div',
|
|
81
95
|
text: {
|
|
82
96
|
tag: 'lark_md',
|
|
83
|
-
content: `**tool:** ${tool}\n**args:** \`${JSON.stringify(args)}\`\n**scope:** ${scope.tenant}/${scope.environment}/${scope.agent}\n**reason:** ${reason}`,
|
|
97
|
+
content: `**tool:** ${tool}\n**args:** \`${JSON.stringify(args)}\`\n**scope:** ${scope.tenant}/${scope.environment}/${scope.agent}\n**reason:** ${reason}${this.decideLine(pendingId, 'approval')}`,
|
|
84
98
|
},
|
|
85
99
|
},
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
...(this.buttons
|
|
101
|
+
? [{
|
|
102
|
+
tag: 'action',
|
|
103
|
+
actions: [
|
|
104
|
+
{ tag: 'button', text: { tag: 'plain_text', content: 'Approve' }, type: 'primary', value: { pendingId, decision: 'approve' } },
|
|
105
|
+
{ tag: 'button', text: { tag: 'plain_text', content: 'Deny' }, type: 'danger', value: { pendingId, decision: 'deny' } },
|
|
106
|
+
],
|
|
107
|
+
}]
|
|
108
|
+
: []),
|
|
93
109
|
],
|
|
94
110
|
};
|
|
95
111
|
await this.sendCard(token, card, pendingId);
|
|
@@ -114,19 +130,23 @@ export class LarkNotifier {
|
|
|
114
130
|
config: { wide_screen_mode: true },
|
|
115
131
|
header: { title: { tag: 'plain_text', content: `Question from ${agent}` } },
|
|
116
132
|
elements: [
|
|
117
|
-
{ tag: 'div', text: { tag: 'lark_md', content: question + (sessionId ? `\n_(session ${sessionId})_` : '') } },
|
|
118
|
-
...(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
name: 'answer_form',
|
|
122
|
-
elements: [
|
|
123
|
-
{ tag: 'input', name: LARK_ANSWER_FIELD_TAG, placeholder: { tag: 'plain_text', content: 'Or type your own answer…' } },
|
|
133
|
+
{ tag: 'div', text: { tag: 'lark_md', content: question + (sessionId ? `\n_(session ${sessionId})_` : '') + this.decideLine(pendingId, 'question') } },
|
|
134
|
+
...(this.buttons
|
|
135
|
+
? [
|
|
136
|
+
...(optionButtons.length ? [{ tag: 'action', actions: optionButtons }] : []),
|
|
124
137
|
{
|
|
125
|
-
tag: '
|
|
126
|
-
|
|
138
|
+
tag: 'form',
|
|
139
|
+
name: 'answer_form',
|
|
140
|
+
elements: [
|
|
141
|
+
{ tag: 'input', name: LARK_ANSWER_FIELD_TAG, placeholder: { tag: 'plain_text', content: 'Or type your own answer…' } },
|
|
142
|
+
{
|
|
143
|
+
tag: 'action',
|
|
144
|
+
actions: [{ tag: 'button', text: { tag: 'plain_text', content: 'Send' }, type: 'primary', action_type: 'form_submit', value: { pendingId } }],
|
|
145
|
+
},
|
|
146
|
+
],
|
|
127
147
|
},
|
|
128
|
-
]
|
|
129
|
-
|
|
148
|
+
]
|
|
149
|
+
: []),
|
|
130
150
|
],
|
|
131
151
|
};
|
|
132
152
|
await this.sendCard(token, card, pendingId);
|
|
@@ -17,6 +17,10 @@ export interface NotifierFile {
|
|
|
17
17
|
}
|
|
18
18
|
/** The env var each channel's secret is read from, per agent. */
|
|
19
19
|
export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
|
|
20
|
+
/** Optional: a Slack app's signing secret, per agent. Set, it turns on
|
|
21
|
+
* Approve/Deny buttons in Slack, handled by the server itself at
|
|
22
|
+
* POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
|
|
23
|
+
export declare const NOTIFIER_SLACK_SIGNING_SECRET_VAR = "LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET";
|
|
20
24
|
export declare class NotifierFileError extends Error {
|
|
21
25
|
}
|
|
22
26
|
export declare function notifierFilePath(dir: string): string;
|
|
@@ -17,6 +17,10 @@ export const NOTIFIER_SECRET_VARS = {
|
|
|
17
17
|
slack: 'LOOPENGINE_NOTIFIER_SLACK_BOT_TOKEN',
|
|
18
18
|
lark: 'LOOPENGINE_NOTIFIER_LARK_APP_SECRET',
|
|
19
19
|
};
|
|
20
|
+
/** Optional: a Slack app's signing secret, per agent. Set, it turns on
|
|
21
|
+
* Approve/Deny buttons in Slack, handled by the server itself at
|
|
22
|
+
* POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
|
|
23
|
+
export const NOTIFIER_SLACK_SIGNING_SECRET_VAR = 'LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET';
|
|
20
24
|
const CHANNELS = ['webhook', 'slack', 'lark'];
|
|
21
25
|
const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
|
|
22
26
|
export class NotifierFileError extends Error {
|
|
@@ -95,10 +99,19 @@ export function notifierConfigFromFile(file, env) {
|
|
|
95
99
|
case 'webhook':
|
|
96
100
|
return { config: { channel: 'webhook', config: { webhookUrl: file.url, webhookSecret: secret, publicUrl: file.publicUrl }, events: file.events } };
|
|
97
101
|
case 'slack':
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
102
|
+
// Buttons only once the agent has a signing secret, i.e. the Slack
|
|
103
|
+
// app's interactivity points at /hooks/slack/<agent>; otherwise the
|
|
104
|
+
// decision link does the job.
|
|
105
|
+
return {
|
|
106
|
+
config: {
|
|
107
|
+
channel: 'slack',
|
|
108
|
+
config: { botToken: secret, channelId: file.channelId, publicUrl: file.publicUrl, buttons: Boolean(env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR)) },
|
|
109
|
+
events: file.events,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
101
112
|
case 'lark':
|
|
102
|
-
|
|
113
|
+
// No card buttons: nothing built in handles Lark's card callbacks yet,
|
|
114
|
+
// so a console-configured Lark notifier relies on the decision link.
|
|
115
|
+
return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
|
|
103
116
|
}
|
|
104
117
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export declare const SLACK_ANSWER_BLOCK_ID = "answer_block";
|
|
2
|
+
export declare const SLACK_ANSWER_ACTION_ID = "answer_input";
|
|
3
|
+
/** https://api.slack.com/authentication/verifying-requests-from-slack —
|
|
4
|
+
* rejects anything older than five minutes, so a captured request can't
|
|
5
|
+
* be replayed later. */
|
|
6
|
+
export declare function verifySlackRequest(rawBody: string, timestamp: string | undefined, signature: string | undefined, signingSecret: string, nowMs?: number): boolean;
|
|
7
|
+
export type SlackInteraction = {
|
|
8
|
+
type: 'decide';
|
|
9
|
+
pendingId: string;
|
|
10
|
+
decision: 'approve' | 'deny';
|
|
11
|
+
responseUrl?: string;
|
|
12
|
+
user?: string;
|
|
13
|
+
} | {
|
|
14
|
+
type: 'answer';
|
|
15
|
+
pendingId: string;
|
|
16
|
+
answer: string;
|
|
17
|
+
responseUrl?: string;
|
|
18
|
+
user?: string;
|
|
19
|
+
} | {
|
|
20
|
+
type: 'open_answer';
|
|
21
|
+
pendingId: string;
|
|
22
|
+
triggerId: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: 'ignore';
|
|
25
|
+
};
|
|
26
|
+
/** Reads Slack's form-encoded `payload=<json>` body into the one thing
|
|
27
|
+
* it asks for. Anything unrecognized is `ignore`. */
|
|
28
|
+
export declare function parseSlackInteraction(rawBody: string): SlackInteraction;
|
|
29
|
+
/** Opens the free-text answer modal for "Other…". Slack's trigger_id
|
|
30
|
+
* expires three seconds after the click, so call this before anything slow. */
|
|
31
|
+
export declare function openSlackAnswerModal(botToken: string, triggerId: string, pendingId: string): Promise<void>;
|
|
32
|
+
/** Replaces the original message (its buttons included) with what
|
|
33
|
+
* happened, so nobody clicks a decision that's already made. */
|
|
34
|
+
export declare function replyInSlack(responseUrl: string, text: string): Promise<void>;
|
|
35
|
+
/** What to say in Slack after a decision, from the resolve route's own
|
|
36
|
+
* response body. */
|
|
37
|
+
export declare function slackOutcomeText(kind: 'approve' | 'deny' | 'answer', status: number, body: {
|
|
38
|
+
alreadyResolved?: boolean;
|
|
39
|
+
resolved?: boolean;
|
|
40
|
+
text?: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
pending?: unknown[];
|
|
43
|
+
}, user?: string): string;
|
|
44
|
+
export interface SlackChannel {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
isPrivate: boolean;
|
|
48
|
+
isMember: boolean;
|
|
49
|
+
}
|
|
50
|
+
/** Channels the bot can see — needs the channels:read (and, for private
|
|
51
|
+
* channels, groups:read) scope. */
|
|
52
|
+
export declare function listSlackChannels(botToken: string): Promise<SlackChannel[]>;
|
|
53
|
+
/** A Slack app manifest for one agent's notifier: a bot that can post and
|
|
54
|
+
* read the channel list, with interactivity pointed at this server.
|
|
55
|
+
* Paste it into api.slack.com/apps → Create New App → From a manifest. */
|
|
56
|
+
export declare function slackAppManifest(options: {
|
|
57
|
+
agentName: string;
|
|
58
|
+
interactivityUrl: string;
|
|
59
|
+
}): Record<string, unknown>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// What the server needs to handle Slack button clicks itself (POST
|
|
2
|
+
// /hooks/slack/<agent> in adapters/http.ts), so a Slack notifier's
|
|
3
|
+
// Approve/Deny and answer buttons work with no handler of your own to
|
|
4
|
+
// deploy: request verification, reading the interaction payload, the
|
|
5
|
+
// "Other…" answer modal, replying in the original message, listing
|
|
6
|
+
// channels for the console's channel picker, and the app manifest the
|
|
7
|
+
// console shows for creating the Slack app.
|
|
8
|
+
//
|
|
9
|
+
// Same button contract SlackNotifier (core/http-notify-triggers/slack.ts)
|
|
10
|
+
// sends: action_id approve|deny with the pendingId as value,
|
|
11
|
+
// answer_option with {pendingId, answer}, open_answer_modal with the
|
|
12
|
+
// pendingId, and the modal's answer under ANSWER_BLOCK_ID/ANSWER_ACTION_ID.
|
|
13
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
14
|
+
export const SLACK_ANSWER_BLOCK_ID = 'answer_block';
|
|
15
|
+
export const SLACK_ANSWER_ACTION_ID = 'answer_input';
|
|
16
|
+
/** https://api.slack.com/authentication/verifying-requests-from-slack —
|
|
17
|
+
* rejects anything older than five minutes, so a captured request can't
|
|
18
|
+
* be replayed later. */
|
|
19
|
+
export function verifySlackRequest(rawBody, timestamp, signature, signingSecret, nowMs = Date.now()) {
|
|
20
|
+
if (!timestamp || !signature)
|
|
21
|
+
return false;
|
|
22
|
+
const age = Math.abs(nowMs / 1000 - Number(timestamp));
|
|
23
|
+
if (!Number.isFinite(age) || age > 60 * 5)
|
|
24
|
+
return false;
|
|
25
|
+
const expected = Buffer.from(`v0=${createHmac('sha256', signingSecret).update(`v0:${timestamp}:${rawBody}`).digest('hex')}`);
|
|
26
|
+
const actual = Buffer.from(signature);
|
|
27
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
28
|
+
}
|
|
29
|
+
/** Reads Slack's form-encoded `payload=<json>` body into the one thing
|
|
30
|
+
* it asks for. Anything unrecognized is `ignore`. */
|
|
31
|
+
export function parseSlackInteraction(rawBody) {
|
|
32
|
+
const raw = new URLSearchParams(rawBody).get('payload');
|
|
33
|
+
if (!raw)
|
|
34
|
+
return { type: 'ignore' };
|
|
35
|
+
let payload;
|
|
36
|
+
try {
|
|
37
|
+
payload = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { type: 'ignore' };
|
|
41
|
+
}
|
|
42
|
+
const user = payload.user?.id;
|
|
43
|
+
if (payload.type === 'view_submission' && payload.view?.private_metadata) {
|
|
44
|
+
const answer = payload.view.state?.values?.[SLACK_ANSWER_BLOCK_ID]?.[SLACK_ANSWER_ACTION_ID]?.value ?? '';
|
|
45
|
+
return { type: 'answer', pendingId: payload.view.private_metadata, answer, user };
|
|
46
|
+
}
|
|
47
|
+
if (payload.type !== 'block_actions')
|
|
48
|
+
return { type: 'ignore' };
|
|
49
|
+
const action = payload.actions?.[0];
|
|
50
|
+
if (!action?.value)
|
|
51
|
+
return { type: 'ignore' };
|
|
52
|
+
switch (action.action_id) {
|
|
53
|
+
case 'approve':
|
|
54
|
+
case 'deny':
|
|
55
|
+
return { type: 'decide', pendingId: action.value, decision: action.action_id, responseUrl: payload.response_url, user };
|
|
56
|
+
case 'answer_option': {
|
|
57
|
+
try {
|
|
58
|
+
const { pendingId, answer } = JSON.parse(action.value);
|
|
59
|
+
return pendingId && typeof answer === 'string' ? { type: 'answer', pendingId, answer, responseUrl: payload.response_url, user } : { type: 'ignore' };
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { type: 'ignore' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
case 'open_answer_modal':
|
|
66
|
+
return payload.trigger_id ? { type: 'open_answer', pendingId: action.value, triggerId: payload.trigger_id } : { type: 'ignore' };
|
|
67
|
+
default:
|
|
68
|
+
return { type: 'ignore' };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Opens the free-text answer modal for "Other…". Slack's trigger_id
|
|
72
|
+
* expires three seconds after the click, so call this before anything slow. */
|
|
73
|
+
export async function openSlackAnswerModal(botToken, triggerId, pendingId) {
|
|
74
|
+
const res = await fetch('https://slack.com/api/views.open', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { Authorization: `Bearer ${botToken}`, 'content-type': 'application/json; charset=utf-8' },
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
trigger_id: triggerId,
|
|
79
|
+
view: {
|
|
80
|
+
type: 'modal',
|
|
81
|
+
callback_id: 'answer_modal',
|
|
82
|
+
private_metadata: pendingId,
|
|
83
|
+
title: { type: 'plain_text', text: 'Your answer' },
|
|
84
|
+
submit: { type: 'plain_text', text: 'Send' },
|
|
85
|
+
blocks: [
|
|
86
|
+
{
|
|
87
|
+
type: 'input',
|
|
88
|
+
block_id: SLACK_ANSWER_BLOCK_ID,
|
|
89
|
+
label: { type: 'plain_text', text: 'Answer' },
|
|
90
|
+
element: { type: 'plain_text_input', action_id: SLACK_ANSWER_ACTION_ID, multiline: true },
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
const body = (await res.json());
|
|
97
|
+
if (!body.ok)
|
|
98
|
+
throw new Error(`views.open failed: ${body.error}`);
|
|
99
|
+
}
|
|
100
|
+
/** Replaces the original message (its buttons included) with what
|
|
101
|
+
* happened, so nobody clicks a decision that's already made. */
|
|
102
|
+
export async function replyInSlack(responseUrl, text) {
|
|
103
|
+
await fetch(responseUrl, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({ replace_original: true, text }),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** What to say in Slack after a decision, from the resolve route's own
|
|
110
|
+
* response body. */
|
|
111
|
+
export function slackOutcomeText(kind, status, body, user) {
|
|
112
|
+
const who = user ? ` by <@${user}>` : '';
|
|
113
|
+
if (status >= 400)
|
|
114
|
+
return `:warning: Couldn't record that: ${body.error ?? `HTTP ${status}`}`;
|
|
115
|
+
if (body.alreadyResolved)
|
|
116
|
+
return 'Already decided elsewhere.';
|
|
117
|
+
const what = kind === 'approve' ? ':white_check_mark: Approved' : kind === 'deny' ? ':no_entry: Denied' : ':speech_balloon: Answered';
|
|
118
|
+
if (body.resolved)
|
|
119
|
+
return `${what}${who}. Still waiting on another decision in the same turn.`;
|
|
120
|
+
if (body.pending?.length)
|
|
121
|
+
return `${what}${who}. The agent needs another decision; a new message is on its way.`;
|
|
122
|
+
return `${what}${who}.${body.text ? `\n>${body.text.replace(/\n/g, '\n>')}` : ''}`;
|
|
123
|
+
}
|
|
124
|
+
/** Channels the bot can see — needs the channels:read (and, for private
|
|
125
|
+
* channels, groups:read) scope. */
|
|
126
|
+
export async function listSlackChannels(botToken) {
|
|
127
|
+
const channels = [];
|
|
128
|
+
let cursor = '';
|
|
129
|
+
for (let page = 0; page < 10; page++) {
|
|
130
|
+
const params = new URLSearchParams({ types: 'public_channel,private_channel', exclude_archived: 'true', limit: '200' });
|
|
131
|
+
if (cursor)
|
|
132
|
+
params.set('cursor', cursor);
|
|
133
|
+
const res = await fetch(`https://slack.com/api/conversations.list?${params}`, { headers: { Authorization: `Bearer ${botToken}` } });
|
|
134
|
+
const body = (await res.json());
|
|
135
|
+
if (!body.ok) {
|
|
136
|
+
const reasons = {
|
|
137
|
+
missing_scope: 'The bot token is missing the channels:read scope; reinstall the app from the manifest',
|
|
138
|
+
invalid_auth: 'Slack rejected the bot token; copy the Bot User OAuth Token (xoxb-...) again',
|
|
139
|
+
not_authed: 'Enter the bot token first',
|
|
140
|
+
token_revoked: 'This bot token was revoked; reinstall the app and copy the new one',
|
|
141
|
+
};
|
|
142
|
+
throw new Error(reasons[body.error ?? ''] ?? `Slack said: ${body.error}`);
|
|
143
|
+
}
|
|
144
|
+
for (const c of body.channels ?? [])
|
|
145
|
+
channels.push({ id: c.id, name: c.name, isPrivate: Boolean(c.is_private), isMember: Boolean(c.is_member) });
|
|
146
|
+
cursor = body.response_metadata?.next_cursor ?? '';
|
|
147
|
+
if (!cursor)
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
return channels.sort((a, b) => Number(b.isMember) - Number(a.isMember) || a.name.localeCompare(b.name));
|
|
151
|
+
}
|
|
152
|
+
/** A Slack app manifest for one agent's notifier: a bot that can post and
|
|
153
|
+
* read the channel list, with interactivity pointed at this server.
|
|
154
|
+
* Paste it into api.slack.com/apps → Create New App → From a manifest. */
|
|
155
|
+
export function slackAppManifest(options) {
|
|
156
|
+
const name = `LoopEngine ${options.agentName}`.slice(0, 35);
|
|
157
|
+
return {
|
|
158
|
+
display_information: { name, description: `Approval requests and questions from the ${options.agentName} agent` },
|
|
159
|
+
features: { bot_user: { display_name: name, always_online: true } },
|
|
160
|
+
oauth_config: { scopes: { bot: ['chat:write', 'channels:read', 'groups:read'] } },
|
|
161
|
+
settings: {
|
|
162
|
+
interactivity: { is_enabled: true, request_url: options.interactivityUrl },
|
|
163
|
+
org_deploy_enabled: false,
|
|
164
|
+
socket_mode_enabled: false,
|
|
165
|
+
token_rotation_enabled: false,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,10 +10,12 @@ export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#cor
|
|
|
10
10
|
export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS, type NotifierFile } from '#core/notifier-file.js';
|
|
11
11
|
export { listDeliveries, recordDelivery, type NotifierDelivery } from '#core/notifier-deliveries.js';
|
|
12
12
|
export { describeNotifier } from '#core/http-notifier.js';
|
|
13
|
+
export { verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, slackOutcomeText, listSlackChannels, slackAppManifest, type SlackInteraction, type SlackChannel } from '#core/slack-interactions.js';
|
|
14
|
+
export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
|
|
13
15
|
export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs, type DecisionKind } from '#core/approval-links.js';
|
|
14
16
|
export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage, type DecidePageItem } from './web/decide-page.js';
|
|
15
17
|
export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
|
|
16
|
-
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, type DefaultWebhook } from './web/notifier-admin.js';
|
|
18
|
+
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels, type DefaultWebhook } from './web/notifier-admin.js';
|
|
17
19
|
export type * from './core/loop-events.js';
|
|
18
20
|
export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
|
|
19
21
|
export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
|
package/dist/index.js
CHANGED
|
@@ -12,10 +12,12 @@ export { setUsageSink, recordUsage } from '#core/usage.js';
|
|
|
12
12
|
export { readNotifierFile, writeNotifierFile, removeNotifierFile, validateNotifierFile, notifierConfigFromFile, NotifierFileError, NOTIFIER_SECRET_VARS } from '#core/notifier-file.js';
|
|
13
13
|
export { listDeliveries, recordDelivery } from '#core/notifier-deliveries.js';
|
|
14
14
|
export { describeNotifier } from '#core/http-notifier.js';
|
|
15
|
+
export { verifySlackRequest, parseSlackInteraction, openSlackAnswerModal, replyInSlack, slackOutcomeText, listSlackChannels, slackAppManifest } from '#core/slack-interactions.js';
|
|
16
|
+
export { NOTIFIER_SLACK_SIGNING_SECRET_VAR } from '#core/notifier-file.js';
|
|
15
17
|
export { createDecisionToken, verifyDecisionToken, decidePath, decideLinkMaker, decisionLinkTtlMs } from '#core/approval-links.js';
|
|
16
18
|
export { renderDecidePage, renderDecideGonePage, renderDecideInvalidPage } from './web/decide-page.js';
|
|
17
19
|
export { sendTestWebhook } from '#core/http-notify-triggers/webhook.js';
|
|
18
|
-
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending } from './web/notifier-admin.js';
|
|
20
|
+
export { describeAgentNotifier, saveAgentNotifier, removeAgentNotifier, testAgentNotifier, listAgentPending, slackCredentialsFor, listAgentSlackChannels } from './web/notifier-admin.js';
|
|
19
21
|
// The framework-agnostic browser client for adapters/http.ts's own wire
|
|
20
22
|
// protocol (see client.ts's own header comment) — this, not a deep
|
|
21
23
|
// subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
|
|
@@ -962,6 +962,7 @@ ${consoleShellScript}
|
|
|
962
962
|
// deliveries. Reloaded every time the tab opens, so the waiting list is
|
|
963
963
|
// current. ----
|
|
964
964
|
|
|
965
|
+
var lastNotifier = null;
|
|
965
966
|
var NOTIFIER_EVENTS = [
|
|
966
967
|
['approval', 'Approvals'], ['question', 'Questions'], ['run_finish', 'Run finished'], ['run_start', 'Run started']
|
|
967
968
|
];
|
|
@@ -1017,8 +1018,27 @@ ${consoleShellScript}
|
|
|
1017
1018
|
'<label>Send to<select name="channel" id="notifierChannel">' +
|
|
1018
1019
|
['webhook', 'slack', 'lark'].map(function (c) { return '<option value="' + c + '"' + (f.channel === c ? ' selected' : '') + '>' + { webhook: 'Webhook (your app)', slack: 'Slack channel', lark: 'Lark / Feishu chat' }[c] + '</option>'; }).join('') +
|
|
1019
1020
|
'</select></label>' +
|
|
1020
|
-
field('webhook', '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField('webhook', 'Signing secret')
|
|
1021
|
-
|
|
1021
|
+
field('webhook', '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField('webhook', 'Signing secret') +
|
|
1022
|
+
'<p class="hint" style="margin:0">Your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries a <code>decideUrl</code> it can simply forward to a person, or it can decide itself by sending <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> to <code>resolveUrl</code>.</p>') +
|
|
1023
|
+
field('slack',
|
|
1024
|
+
'<details class="slack-step"' + (n.secretsSet && n.secretsSet.slack ? '' : ' open') + '><summary>Create the Slack app (once)</summary>' +
|
|
1025
|
+
'<ol class="hint" style="margin:6px 0;padding-left:18px">' +
|
|
1026
|
+
'<li>Open <a href="https://api.slack.com/apps?new_app=1" target="_blank" rel="noopener">Slack apps</a> → Create New App → From a manifest, and pick your workspace.</li>' +
|
|
1027
|
+
'<li>Paste the manifest below, create the app, then Install to Workspace.</li>' +
|
|
1028
|
+
'<li>Copy the <strong>Bot User OAuth Token</strong> (starts with xoxb-) from OAuth & Permissions into the field below.</li>' +
|
|
1029
|
+
'</ol>' +
|
|
1030
|
+
'<textarea id="slackManifest" readonly rows="8" style="font-family:var(--font-mono);font-size:11.5px"></textarea>' +
|
|
1031
|
+
'<button type="button" id="slackManifestCopy" style="margin-top:6px">Copy manifest</button>' +
|
|
1032
|
+
'</details>' +
|
|
1033
|
+
secretField('slack', 'Bot token') +
|
|
1034
|
+
'<label>Channel <span class="hint">(invite the bot to a private channel first: /invite @its-name)</span>' +
|
|
1035
|
+
'<span style="display:flex;gap:6px"><select id="slackChannelSelect" style="flex:1;min-width:0"><option value="">' + (f.channelId ? 'Saved: ' + escapeHtml(f.channelId) : 'Load channels to pick one') + '</option></select>' +
|
|
1036
|
+
'<button type="button" id="slackLoadChannels">Load channels</button></span></label>' +
|
|
1037
|
+
'<label>Channel ID <span class="hint">(filled in when you pick a channel)</span><input name="channelId" placeholder="C0123ABC456" value="' + escapeHtml(f.channelId || '') + '"></label>' +
|
|
1038
|
+
'<label>Signing secret <span class="hint">(optional, from Basic Information; ' +
|
|
1039
|
+
(n.slack && n.slack.signingSecretSet ? 'set — Approve/Deny buttons are on' : 'adds Approve/Deny buttons inside Slack; without it, messages carry the approval link') + ')</span>' +
|
|
1040
|
+
'<input type="password" name="signingSecret" autocomplete="off"></label>' +
|
|
1041
|
+
'<p class="hint" style="margin:0">Buttons send their clicks to <code id="slackInteractivityUrl"></code> (the manifest already points there).</p>') +
|
|
1022
1042
|
field('lark', '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField('lark', 'App secret')) +
|
|
1023
1043
|
'<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
|
|
1024
1044
|
'<input type="url" name="publicUrl" id="notifierPublicUrl" value="' + escapeHtml(n.publicUrl || f.publicUrl || location.origin) + '"' + (n.publicUrl ? ' disabled title="Set by LOOPENGINE_PUBLIC_URL"' : '') + '></label>' +
|
|
@@ -1031,7 +1051,7 @@ ${consoleShellScript}
|
|
|
1031
1051
|
'<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit"' + (n.canSave === false ? ' disabled' : '') + '>Save</button><button type="button" id="notifierTestBtn">Send test</button>' +
|
|
1032
1052
|
(n.file ? '<button type="button" class="delete-btn" id="notifierRemoveBtn">Remove</button>' : '') + '</div>' +
|
|
1033
1053
|
'<div id="notifierResult" class="hint"></div>' +
|
|
1034
|
-
|
|
1054
|
+
|
|
1035
1055
|
'</form>';
|
|
1036
1056
|
}
|
|
1037
1057
|
|
|
@@ -1142,6 +1162,51 @@ ${consoleShellScript}
|
|
|
1142
1162
|
result.textContent = 'Copy this secret into your app before saving; it is not shown again.';
|
|
1143
1163
|
});
|
|
1144
1164
|
}
|
|
1165
|
+
// Slack: manifest follows the link address; Load channels fills the picker.
|
|
1166
|
+
var n0 = lastNotifier;
|
|
1167
|
+
var manifestEl = form.querySelector('#slackManifest');
|
|
1168
|
+
var linkInput = form.querySelector('#notifierPublicUrl');
|
|
1169
|
+
function refreshSlackManifest() {
|
|
1170
|
+
if (!manifestEl || !n0 || !n0.slack) return;
|
|
1171
|
+
var url = (linkInput.value || location.origin).replace(/[/]+$/, '') + n0.slack.interactivityPath;
|
|
1172
|
+
manifestEl.value = JSON.stringify(n0.slack.manifestTemplate, null, 2).replace('__INTERACTIVITY_URL__', url);
|
|
1173
|
+
var urlEl = form.querySelector('#slackInteractivityUrl');
|
|
1174
|
+
if (urlEl) urlEl.textContent = url;
|
|
1175
|
+
}
|
|
1176
|
+
if (linkInput) linkInput.addEventListener('input', refreshSlackManifest);
|
|
1177
|
+
refreshSlackManifest();
|
|
1178
|
+
var copyManifest = form.querySelector('#slackManifestCopy');
|
|
1179
|
+
if (copyManifest) {
|
|
1180
|
+
copyManifest.addEventListener('click', function () {
|
|
1181
|
+
manifestEl.select();
|
|
1182
|
+
var done = function () { copyManifest.textContent = 'Copied'; setTimeout(function () { copyManifest.textContent = 'Copy manifest'; }, 1500); };
|
|
1183
|
+
if (navigator.clipboard) navigator.clipboard.writeText(manifestEl.value).then(done, done); else done();
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
var loadChannels = form.querySelector('#slackLoadChannels');
|
|
1187
|
+
var channelSelect = form.querySelector('#slackChannelSelect');
|
|
1188
|
+
if (loadChannels) {
|
|
1189
|
+
loadChannels.addEventListener('click', function () {
|
|
1190
|
+
loadChannels.disabled = true;
|
|
1191
|
+
result.textContent = 'Loading channels' + String.fromCharCode(8230);
|
|
1192
|
+
var token = form.querySelector('input[name="secret_slack"]').value;
|
|
1193
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/slack-channels', {
|
|
1194
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ botToken: token }),
|
|
1195
|
+
}).then(function (r) {
|
|
1196
|
+
loadChannels.disabled = false;
|
|
1197
|
+
if (!r.ok) { result.textContent = r.body.error || 'Could not load channels'; return; }
|
|
1198
|
+
var current = form.querySelector('input[name="channelId"]').value;
|
|
1199
|
+
channelSelect.innerHTML = '<option value="">Pick a channel</option>' + r.body.channels.map(function (c) {
|
|
1200
|
+
return '<option value="' + escapeHtml(c.id) + '"' + (c.id === current ? ' selected' : '') + '>' + (c.isPrivate ? '🔒 ' : '#') + escapeHtml(c.name) + (c.isMember ? '' : ' (bot not in channel yet)') + '</option>';
|
|
1201
|
+
}).join('');
|
|
1202
|
+
result.textContent = r.body.channels.length + ' channels found.';
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
channelSelect.addEventListener('change', function () {
|
|
1206
|
+
if (channelSelect.value) form.querySelector('input[name="channelId"]').value = channelSelect.value;
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1145
1210
|
form.addEventListener('submit', function (ev) {
|
|
1146
1211
|
ev.preventDefault();
|
|
1147
1212
|
var data = new FormData(form);
|
|
@@ -1149,6 +1214,7 @@ ${consoleShellScript}
|
|
|
1149
1214
|
var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId'), publicUrl: data.get('publicUrl') };
|
|
1150
1215
|
var secret = data.get('secret_' + channel);
|
|
1151
1216
|
if (secret) body.secret = secret;
|
|
1217
|
+
if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
|
|
1152
1218
|
result.textContent = 'Saving…';
|
|
1153
1219
|
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', {
|
|
1154
1220
|
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
@@ -1182,6 +1248,7 @@ ${consoleShellScript}
|
|
|
1182
1248
|
]).then(function (results) {
|
|
1183
1249
|
if (currentName !== name) return;
|
|
1184
1250
|
if (!results[0].ok) throw new Error(results[0].body.error || 'request failed');
|
|
1251
|
+
lastNotifier = results[0].body;
|
|
1185
1252
|
content.innerHTML = renderApprovalsHtml(results[0].body);
|
|
1186
1253
|
wireApprovalsHandlers(name);
|
|
1187
1254
|
var pendingEl = detail.querySelector('#pendingContent');
|
|
@@ -2,6 +2,7 @@ import type { AgentConfig } from '../core/agent-config.js';
|
|
|
2
2
|
import type { CheckpointStore } from '../core/durable-approvals.js';
|
|
3
3
|
import { type DeliveryResult } from '../core/http-notify-triggers/webhook.js';
|
|
4
4
|
import { type NotifierFileChannel } from '../core/notifier-file.js';
|
|
5
|
+
import { type SlackChannel } from '../core/slack-interactions.js';
|
|
5
6
|
/** The deployment-wide default webhook (LOOPENGINE_DEFAULT_WEBHOOK_URL /
|
|
6
7
|
* _SECRET), which applies to the plain /messages route when an agent has
|
|
7
8
|
* no notifier of its own. */
|
|
@@ -17,6 +18,12 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
|
|
|
17
18
|
secretsSet: {
|
|
18
19
|
[k: string]: boolean;
|
|
19
20
|
};
|
|
21
|
+
slack: {
|
|
22
|
+
signingSecretVar: string;
|
|
23
|
+
signingSecretSet: boolean;
|
|
24
|
+
interactivityPath: string;
|
|
25
|
+
manifestTemplate: Record<string, unknown>;
|
|
26
|
+
};
|
|
20
27
|
deliveries: import("../core/notifier-deliveries.js").NotifierDelivery[];
|
|
21
28
|
channel?: import("../core/agent-config.js").HttpNotifierConfig["channel"];
|
|
22
29
|
events?: string[];
|
|
@@ -28,6 +35,17 @@ export declare function describeAgentNotifier(config: AgentConfig, defaultWebhoo
|
|
|
28
35
|
* form includes one, the channel's secret into the agent's own env.
|
|
29
36
|
* Throws NotifierFileError for an invalid form. */
|
|
30
37
|
export declare function saveAgentNotifier(config: AgentConfig, body: Record<string, unknown>): Promise<void>;
|
|
38
|
+
/** The bot token and signing secret behind an agent's Slack notifier —
|
|
39
|
+
* from AgentConfig.httpNotifier when it's Slack, else from the agent's
|
|
40
|
+
* env (the console-saved ones). The signing secret always comes from the
|
|
41
|
+
* agent env. */
|
|
42
|
+
export declare function slackCredentialsFor(config: AgentConfig): {
|
|
43
|
+
botToken?: string;
|
|
44
|
+
signingSecret?: string;
|
|
45
|
+
};
|
|
46
|
+
/** Channels for the console's picker, with the token being typed in or,
|
|
47
|
+
* when none is given, the one already saved. */
|
|
48
|
+
export declare function listAgentSlackChannels(config: AgentConfig, botToken?: string): Promise<SlackChannel[]>;
|
|
31
49
|
/** Removes notifier.yml; the stored secret is left alone, so turning the
|
|
32
50
|
* notifier back on doesn't need it re-entered. */
|
|
33
51
|
export declare function removeAgentNotifier(config: AgentConfig): void;
|
|
@@ -4,7 +4,8 @@ import { describeNotifier } from '../core/http-notifier.js';
|
|
|
4
4
|
import { sendTestSlackMessage } from '../core/http-notify-triggers/slack.js';
|
|
5
5
|
import { sendTestWebhook } from '../core/http-notify-triggers/webhook.js';
|
|
6
6
|
import { listDeliveries } from '../core/notifier-deliveries.js';
|
|
7
|
-
import { NOTIFIER_SECRET_VARS, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
|
|
7
|
+
import { NOTIFIER_SECRET_VARS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, notifierConfigFromFile, readNotifierFile, removeNotifierFile, writeNotifierFile } from '../core/notifier-file.js';
|
|
8
|
+
import { listSlackChannels, slackAppManifest } from '../core/slack-interactions.js';
|
|
8
9
|
import { setAgentEnvVar } from './env-admin.js';
|
|
9
10
|
export function describeAgentNotifier(config, defaultWebhook) {
|
|
10
11
|
const described = describeNotifier(config);
|
|
@@ -18,6 +19,15 @@ export function describeAgentNotifier(config, defaultWebhook) {
|
|
|
18
19
|
publicUrl: process.env.LOOPENGINE_PUBLIC_URL ?? null,
|
|
19
20
|
secretVars: NOTIFIER_SECRET_VARS,
|
|
20
21
|
secretsSet,
|
|
22
|
+
slack: {
|
|
23
|
+
signingSecretVar: NOTIFIER_SLACK_SIGNING_SECRET_VAR,
|
|
24
|
+
signingSecretSet: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) !== undefined,
|
|
25
|
+
// Where the Slack app's Interactivity Request URL should point.
|
|
26
|
+
interactivityPath: `/hooks/slack/${encodeURIComponent(config.name)}`,
|
|
27
|
+
// The console swaps __INTERACTIVITY_URL__ for the link address +
|
|
28
|
+
// interactivityPath as you type.
|
|
29
|
+
manifestTemplate: slackAppManifest({ agentName: config.name, interactivityUrl: '__INTERACTIVITY_URL__' }),
|
|
30
|
+
},
|
|
21
31
|
deliveries: listDeliveries(config.name),
|
|
22
32
|
};
|
|
23
33
|
}
|
|
@@ -29,6 +39,26 @@ export async function saveAgentNotifier(config, body) {
|
|
|
29
39
|
if (typeof body.secret === 'string' && body.secret.trim()) {
|
|
30
40
|
await setAgentEnvVar(config.name, NOTIFIER_SECRET_VARS[file.channel], body.secret.trim());
|
|
31
41
|
}
|
|
42
|
+
if (file.channel === 'slack' && typeof body.signingSecret === 'string' && body.signingSecret.trim()) {
|
|
43
|
+
await setAgentEnvVar(config.name, NOTIFIER_SLACK_SIGNING_SECRET_VAR, body.signingSecret.trim());
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** The bot token and signing secret behind an agent's Slack notifier —
|
|
47
|
+
* from AgentConfig.httpNotifier when it's Slack, else from the agent's
|
|
48
|
+
* env (the console-saved ones). The signing secret always comes from the
|
|
49
|
+
* agent env. */
|
|
50
|
+
export function slackCredentialsFor(config) {
|
|
51
|
+
const env = createAgentEnv(config.name, agentDirFor(config));
|
|
52
|
+
const botToken = config.httpNotifier?.channel === 'slack' ? config.httpNotifier.config.botToken : env.get(NOTIFIER_SECRET_VARS.slack);
|
|
53
|
+
return { botToken, signingSecret: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) };
|
|
54
|
+
}
|
|
55
|
+
/** Channels for the console's picker, with the token being typed in or,
|
|
56
|
+
* when none is given, the one already saved. */
|
|
57
|
+
export async function listAgentSlackChannels(config, botToken) {
|
|
58
|
+
const token = botToken?.trim() || slackCredentialsFor(config).botToken;
|
|
59
|
+
if (!token)
|
|
60
|
+
throw new Error('Enter the bot token first');
|
|
61
|
+
return listSlackChannels(token);
|
|
32
62
|
}
|
|
33
63
|
/** Removes notifier.yml; the stored secret is left alone, so turning the
|
|
34
64
|
* notifier back on doesn't need it re-entered. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.69",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,6 +50,8 @@
|
|
|
50
50
|
"dist/core/notifier-deliveries.d.ts",
|
|
51
51
|
"dist/core/approval-links.js",
|
|
52
52
|
"dist/core/approval-links.d.ts",
|
|
53
|
+
"dist/core/slack-interactions.js",
|
|
54
|
+
"dist/core/slack-interactions.d.ts",
|
|
53
55
|
"dist/core/session-store.js",
|
|
54
56
|
"dist/core/session-store.d.ts",
|
|
55
57
|
"dist/core/vector-index.js",
|