openbot 0.5.9 → 0.5.11
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/dist/app/active-runs.js +70 -0
- package/dist/app/cli.js +1 -1
- package/dist/app/lifecycle-events.js +27 -0
- package/dist/app/run-event-handler.js +37 -0
- package/dist/app/server.js +124 -124
- package/dist/app/slack-webhook.js +81 -0
- package/dist/app/webhook-routing.js +52 -0
- package/dist/harness/index.js +11 -14
- package/dist/plugins/slack-webhook/index.js +51 -0
- package/dist/plugins/storage/index.js +72 -14
- package/dist/plugins/storage/service.js +7 -1
- package/docs/plugins.md +39 -0
- package/package.json +2 -2
- package/src/app/active-runs.ts +95 -0
- package/src/app/cli.ts +1 -1
- package/src/app/lifecycle-events.ts +46 -0
- package/src/app/run-event-handler.ts +55 -0
- package/src/app/server.ts +143 -154
- package/src/app/types.ts +32 -4
- package/src/app/webhook-routing.ts +70 -0
- package/src/harness/index.ts +10 -19
- package/src/plugins/storage/index.ts +432 -372
- package/src/plugins/storage/service.ts +9 -3
- package/src/services/plugins/types.ts +0 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export function getRunKey(runId, agentId, channelId, threadId) {
|
|
2
|
+
return `${runId}:${agentId}:${channelId}:${threadId || ''}`;
|
|
3
|
+
}
|
|
4
|
+
export class ActiveRunsTracker {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.runs = new Map();
|
|
7
|
+
}
|
|
8
|
+
trackStart(data) {
|
|
9
|
+
this.runs.set(getRunKey(data.runId, data.agentId, data.channelId, data.threadId), data);
|
|
10
|
+
}
|
|
11
|
+
trackEnd(data) {
|
|
12
|
+
this.runs.delete(getRunKey(data.runId, data.agentId, data.channelId, data.threadId));
|
|
13
|
+
}
|
|
14
|
+
buildSnapshot() {
|
|
15
|
+
const byBucket = new Map();
|
|
16
|
+
for (const run of this.runs.values()) {
|
|
17
|
+
const threadId = run.threadId || undefined;
|
|
18
|
+
const key = JSON.stringify([run.channelId, threadId ?? null]);
|
|
19
|
+
let bucket = byBucket.get(key);
|
|
20
|
+
if (!bucket) {
|
|
21
|
+
bucket = {
|
|
22
|
+
channelId: run.channelId,
|
|
23
|
+
threadId,
|
|
24
|
+
activeCount: 0,
|
|
25
|
+
agentIds: new Set(),
|
|
26
|
+
};
|
|
27
|
+
byBucket.set(key, bucket);
|
|
28
|
+
}
|
|
29
|
+
bucket.activeCount += 1;
|
|
30
|
+
bucket.agentIds.add(run.agentId);
|
|
31
|
+
}
|
|
32
|
+
const channels = Array.from(byBucket.values())
|
|
33
|
+
.sort((a, b) => {
|
|
34
|
+
const c = a.channelId.localeCompare(b.channelId);
|
|
35
|
+
if (c !== 0)
|
|
36
|
+
return c;
|
|
37
|
+
return (a.threadId ?? '').localeCompare(b.threadId ?? '');
|
|
38
|
+
})
|
|
39
|
+
.map(({ channelId, threadId, activeCount, agentIds }) => {
|
|
40
|
+
const row = {
|
|
41
|
+
channelId,
|
|
42
|
+
activeCount,
|
|
43
|
+
agentIds: Array.from(agentIds),
|
|
44
|
+
};
|
|
45
|
+
if (threadId !== undefined) {
|
|
46
|
+
row.threadId = threadId;
|
|
47
|
+
}
|
|
48
|
+
return row;
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
type: 'agent:active-runs:snapshot',
|
|
52
|
+
data: { channels },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Drop every tracked run for a channel/thread. Returns the purged records so
|
|
57
|
+
* callers can emit synthetic `agent:run:end` events to SSE clients.
|
|
58
|
+
*/
|
|
59
|
+
purgeForThread(channelId, threadId) {
|
|
60
|
+
const target = threadId || undefined;
|
|
61
|
+
const removed = [];
|
|
62
|
+
for (const [key, run] of this.runs) {
|
|
63
|
+
if (run.channelId === channelId && (run.threadId || undefined) === target) {
|
|
64
|
+
removed.push(run);
|
|
65
|
+
this.runs.delete(key);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return removed;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/app/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ function checkNodeVersion() {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
checkNodeVersion();
|
|
21
|
-
program.name('openbot').description('OpenBot CLI').version('0.5.
|
|
21
|
+
program.name('openbot').description('OpenBot CLI').version('0.5.11');
|
|
22
22
|
program
|
|
23
23
|
.command('start')
|
|
24
24
|
.description('Start the OpenBot harness')
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { storageService } from '../plugins/storage/service.js';
|
|
2
|
+
import { ensureEventId } from './utils.js';
|
|
3
|
+
export function persistLifecycleEvent(event, targetChannelId, targetThreadId) {
|
|
4
|
+
ensureEventId(event);
|
|
5
|
+
storageService
|
|
6
|
+
.storeEvent({
|
|
7
|
+
channelId: targetChannelId,
|
|
8
|
+
threadId: targetThreadId,
|
|
9
|
+
event,
|
|
10
|
+
})
|
|
11
|
+
.catch((error) => {
|
|
12
|
+
console.error('[server] Failed to persist lifecycle event', {
|
|
13
|
+
type: event.type,
|
|
14
|
+
channelId: targetChannelId,
|
|
15
|
+
threadId: targetThreadId,
|
|
16
|
+
error,
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export function broadcastLifecycleEvent(event, channelId, threadId, deps, options) {
|
|
21
|
+
ensureEventId(event);
|
|
22
|
+
if (options?.persist !== false) {
|
|
23
|
+
persistLifecycleEvent(event, channelId, threadId);
|
|
24
|
+
}
|
|
25
|
+
deps.sendToClientKey(deps.getClientKey(channelId, threadId), event);
|
|
26
|
+
deps.sendToClientKey(deps.globalChannelId, event);
|
|
27
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const LIFECYCLE_RUN_TYPES = new Set(['agent:run:start', 'agent:run:end', 'agent:run:stopped']);
|
|
2
|
+
export function createRunEventHandler(deps) {
|
|
3
|
+
return async (chunk, state) => {
|
|
4
|
+
if (chunk.type === 'action:webhook:http-response' && deps.onWebhookHttpResponse) {
|
|
5
|
+
deps.onWebhookHttpResponse(chunk.data);
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
const targetChannelId = state?.channelId || deps.defaultChannelId;
|
|
9
|
+
const targetThreadId = state?.threadId || deps.defaultThreadId;
|
|
10
|
+
if (chunk.type === 'agent:run:start' && chunk.data.channelId) {
|
|
11
|
+
deps.activeRuns.trackStart({
|
|
12
|
+
runId: chunk.data.runId,
|
|
13
|
+
channelId: chunk.data.channelId,
|
|
14
|
+
threadId: chunk.data.threadId,
|
|
15
|
+
agentId: chunk.data.agentId,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
else if (chunk.type === 'agent:run:end' && chunk.data.channelId) {
|
|
19
|
+
deps.activeRuns.trackEnd({
|
|
20
|
+
runId: chunk.data.runId,
|
|
21
|
+
channelId: chunk.data.channelId,
|
|
22
|
+
threadId: chunk.data.threadId,
|
|
23
|
+
agentId: chunk.data.agentId,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
else if (chunk.type === 'agent:run:stopped' && chunk.data.channelId) {
|
|
27
|
+
deps.onRunStopped?.(chunk.data.channelId, chunk.data.threadId);
|
|
28
|
+
}
|
|
29
|
+
if (targetChannelId) {
|
|
30
|
+
const targetClientKey = deps.getClientKey(targetChannelId, targetThreadId);
|
|
31
|
+
deps.sendToClientKey(targetClientKey, chunk);
|
|
32
|
+
}
|
|
33
|
+
if (LIFECYCLE_RUN_TYPES.has(chunk.type)) {
|
|
34
|
+
deps.sendToClientKey(deps.globalChannelId, chunk);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/app/server.js
CHANGED
|
@@ -10,6 +10,9 @@ const pkg = require('../../package.json');
|
|
|
10
10
|
import { generateId } from 'melony';
|
|
11
11
|
import { getBaseDir, loadConfig } from '../app/config.js';
|
|
12
12
|
import { isCloudMode } from './cloud-mode.js';
|
|
13
|
+
import { ActiveRunsTracker } from './active-runs.js';
|
|
14
|
+
import { broadcastLifecycleEvent } from './lifecycle-events.js';
|
|
15
|
+
import { createRunEventHandler } from './run-event-handler.js';
|
|
13
16
|
import { processService } from '../services/process.js';
|
|
14
17
|
import { runAgent, STATE_AGENT_ID, ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
|
|
15
18
|
import { initPlugins } from '../services/plugins/registry.js';
|
|
@@ -18,7 +21,8 @@ import { buildWorkspaceFileUrl, getPublicBaseUrl, openChannelFileStream, } from
|
|
|
18
21
|
import { ensureEventId, openBotEventFromQuery } from './utils.js';
|
|
19
22
|
import { abortRegistry, abortKey } from '../services/abort.js';
|
|
20
23
|
import { resolvePublishTargetAgentId, resolveRespondingAgentId } from './responding-agent.js';
|
|
21
|
-
import { DEFAULT_UNCATEGORIZED_SPEC, UNCATEGORIZED_CHANNEL_ID
|
|
24
|
+
import { DEFAULT_UNCATEGORIZED_SPEC, UNCATEGORIZED_CHANNEL_ID } from './channel-ids.js';
|
|
25
|
+
import { pickWebhookHeaders, resolveWebhookAgentId, respondAgentDispatchError, sendWebhookHttpResponse, } from './webhook-routing.js';
|
|
22
26
|
export async function startServer(options = {}) {
|
|
23
27
|
const publishEventSchema = z
|
|
24
28
|
.object({
|
|
@@ -38,19 +42,21 @@ export async function startServer(options = {}) {
|
|
|
38
42
|
const app = express();
|
|
39
43
|
const clients = new Map();
|
|
40
44
|
const GLOBAL_CHANNEL_ID = '__global__';
|
|
41
|
-
const
|
|
45
|
+
const activeRunsTracker = new ActiveRunsTracker();
|
|
42
46
|
const agentsDir = path.join(openBotDir, 'agents');
|
|
43
47
|
const pluginsDir = path.join(openBotDir, 'plugins');
|
|
44
48
|
await fs.mkdir(agentsDir, { recursive: true });
|
|
45
49
|
await fs.mkdir(pluginsDir, { recursive: true });
|
|
46
50
|
initPlugins(pluginsDir);
|
|
47
51
|
// Pre-warm caches for agents and plugins to speed up first UI load
|
|
48
|
-
storageService
|
|
49
|
-
|
|
52
|
+
storageService
|
|
53
|
+
.getAgents()
|
|
54
|
+
.catch((err) => console.warn('[server] Failed to pre-warm agents cache', err));
|
|
55
|
+
storageService
|
|
56
|
+
.getPlugins()
|
|
57
|
+
.catch((err) => console.warn('[server] Failed to pre-warm plugins cache', err));
|
|
50
58
|
const getRawChannelId = (req) => {
|
|
51
|
-
const raw = req.get('x-openbot-channel-id') ||
|
|
52
|
-
req.query.channelId ||
|
|
53
|
-
(req.body && req.body.channelId);
|
|
59
|
+
const raw = req.get('x-openbot-channel-id') || req.query.channelId || (req.body && req.body.channelId);
|
|
54
60
|
if (typeof raw !== 'string')
|
|
55
61
|
return undefined;
|
|
56
62
|
const trimmed = raw.trim();
|
|
@@ -77,7 +83,6 @@ export async function startServer(options = {}) {
|
|
|
77
83
|
};
|
|
78
84
|
};
|
|
79
85
|
const getClientKey = (channelId, threadId) => threadId ? `${channelId}:${threadId}` : channelId;
|
|
80
|
-
const getRunKey = (runId, agentId, channelId, threadId) => `${runId}:${agentId}:${channelId}:${threadId || ''}`;
|
|
81
86
|
const sendToClientKey = (clientKey, chunk) => {
|
|
82
87
|
const threadClients = clients.get(clientKey);
|
|
83
88
|
if (!threadClients || threadClients.length === 0)
|
|
@@ -87,7 +92,9 @@ export async function startServer(options = {}) {
|
|
|
87
92
|
const parts = clientKey.split(':');
|
|
88
93
|
const channelId = parts[0];
|
|
89
94
|
const threadId = parts[1]; // undefined if no ":"
|
|
90
|
-
storageService
|
|
95
|
+
storageService
|
|
96
|
+
.setLastRead({ channelId, threadId, lastReadEventId: chunk.id })
|
|
97
|
+
.catch(() => { });
|
|
91
98
|
}
|
|
92
99
|
threadClients.forEach((client) => {
|
|
93
100
|
if (!client.writableEnded) {
|
|
@@ -95,64 +102,16 @@ export async function startServer(options = {}) {
|
|
|
95
102
|
}
|
|
96
103
|
});
|
|
97
104
|
};
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const key = JSON.stringify([run.channelId, threadId ?? null]);
|
|
103
|
-
let bucket = byBucket.get(key);
|
|
104
|
-
if (!bucket) {
|
|
105
|
-
bucket = { channelId: run.channelId, threadId, activeCount: 0, agentIds: new Set() };
|
|
106
|
-
byBucket.set(key, bucket);
|
|
107
|
-
}
|
|
108
|
-
bucket.activeCount += 1;
|
|
109
|
-
bucket.agentIds.add(run.agentId);
|
|
110
|
-
}
|
|
111
|
-
const channels = Array.from(byBucket.values())
|
|
112
|
-
.sort((a, b) => {
|
|
113
|
-
const c = a.channelId.localeCompare(b.channelId);
|
|
114
|
-
if (c !== 0)
|
|
115
|
-
return c;
|
|
116
|
-
return (a.threadId ?? '').localeCompare(b.threadId ?? '');
|
|
117
|
-
})
|
|
118
|
-
.map(({ channelId, threadId, activeCount, agentIds }) => {
|
|
119
|
-
const row = {
|
|
120
|
-
channelId,
|
|
121
|
-
activeCount,
|
|
122
|
-
agentIds: Array.from(agentIds),
|
|
123
|
-
};
|
|
124
|
-
if (threadId !== undefined) {
|
|
125
|
-
row.threadId = threadId;
|
|
126
|
-
}
|
|
127
|
-
return row;
|
|
128
|
-
});
|
|
129
|
-
return {
|
|
130
|
-
type: 'agent:active-runs:snapshot',
|
|
131
|
-
data: { channels },
|
|
132
|
-
};
|
|
105
|
+
const lifecycleDeps = {
|
|
106
|
+
sendToClientKey,
|
|
107
|
+
getClientKey,
|
|
108
|
+
globalChannelId: GLOBAL_CHANNEL_ID,
|
|
133
109
|
};
|
|
134
110
|
const broadcastActiveRunsSnapshot = () => {
|
|
135
|
-
const snapshot =
|
|
111
|
+
const snapshot = activeRunsTracker.buildSnapshot();
|
|
136
112
|
ensureEventId(snapshot);
|
|
137
113
|
sendToClientKey(GLOBAL_CHANNEL_ID, snapshot);
|
|
138
114
|
};
|
|
139
|
-
const persistLifecycleEvent = (event, targetChannelId, targetThreadId) => {
|
|
140
|
-
ensureEventId(event);
|
|
141
|
-
storageService
|
|
142
|
-
.storeEvent({
|
|
143
|
-
channelId: targetChannelId,
|
|
144
|
-
threadId: targetThreadId,
|
|
145
|
-
event,
|
|
146
|
-
})
|
|
147
|
-
.catch((error) => {
|
|
148
|
-
console.error('[server] Failed to persist lifecycle event', {
|
|
149
|
-
type: event.type,
|
|
150
|
-
channelId: targetChannelId,
|
|
151
|
-
threadId: targetThreadId,
|
|
152
|
-
error,
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
};
|
|
156
115
|
// Drop every tracked run for a channel/thread. A stop aborts the whole
|
|
157
116
|
// chain (parent + delegated sub-agents), but the sub-agents' `agent:run:end`
|
|
158
117
|
// events can be swallowed when the parent run loop breaks on abort, leaving
|
|
@@ -160,15 +119,7 @@ export async function startServer(options = {}) {
|
|
|
160
119
|
// `agent:run:end` for each purged run and refresh the global snapshot so
|
|
161
120
|
// clients stay in sync even when the parent harness stops yielding.
|
|
162
121
|
const purgeActiveRunsForThread = (channelId, threadId) => {
|
|
163
|
-
const
|
|
164
|
-
const removed = [];
|
|
165
|
-
for (const [key, run] of activeRuns) {
|
|
166
|
-
if (run.channelId === channelId && (run.threadId || undefined) === target) {
|
|
167
|
-
removed.push(run);
|
|
168
|
-
activeRuns.delete(key);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
for (const run of removed) {
|
|
122
|
+
for (const run of activeRunsTracker.purgeForThread(channelId, threadId)) {
|
|
172
123
|
const endEvent = {
|
|
173
124
|
type: 'agent:run:end',
|
|
174
125
|
data: {
|
|
@@ -178,12 +129,13 @@ export async function startServer(options = {}) {
|
|
|
178
129
|
threadId: run.threadId,
|
|
179
130
|
},
|
|
180
131
|
};
|
|
181
|
-
|
|
182
|
-
persistLifecycleEvent(endEvent, run.channelId, run.threadId);
|
|
183
|
-
sendToClientKey(getClientKey(run.channelId, run.threadId), endEvent);
|
|
184
|
-
sendToClientKey(GLOBAL_CHANNEL_ID, endEvent);
|
|
132
|
+
broadcastLifecycleEvent(endEvent, run.channelId, run.threadId, lifecycleDeps);
|
|
185
133
|
}
|
|
186
134
|
};
|
|
135
|
+
const handleRunStopped = (channelId, threadId) => {
|
|
136
|
+
purgeActiveRunsForThread(channelId, threadId);
|
|
137
|
+
broadcastActiveRunsSnapshot();
|
|
138
|
+
};
|
|
187
139
|
// Support for Chrome's Private Network Access (PNA)
|
|
188
140
|
// https://developer.chrome.com/blog/private-network-access-preflight/
|
|
189
141
|
app.use((req, res, next) => {
|
|
@@ -200,7 +152,9 @@ export async function startServer(options = {}) {
|
|
|
200
152
|
const gatewayToken = process.env.OPENBOT_GATEWAY_TOKEN?.trim();
|
|
201
153
|
if (gatewayToken) {
|
|
202
154
|
app.use((req, res, next) => {
|
|
203
|
-
if (req.path === '/api/health' ||
|
|
155
|
+
if (req.path === '/api/health' ||
|
|
156
|
+
req.method === 'OPTIONS' ||
|
|
157
|
+
req.path.startsWith('/api/webhooks/')) {
|
|
204
158
|
next();
|
|
205
159
|
return;
|
|
206
160
|
}
|
|
@@ -217,8 +171,9 @@ export async function startServer(options = {}) {
|
|
|
217
171
|
const isWorkspaceUpload = req.method === 'POST' &&
|
|
218
172
|
req.path === '/api/publish' &&
|
|
219
173
|
req.get('x-openbot-event-type') === 'action:storage:upload-file';
|
|
220
|
-
|
|
221
|
-
|
|
174
|
+
const isWebhook = req.method === 'POST' && req.path.startsWith('/api/webhooks/');
|
|
175
|
+
if (isWorkspaceUpload || isWebhook) {
|
|
176
|
+
express.raw({ type: () => true, limit: isWebhook ? '5mb' : '100mb' })(req, res, next);
|
|
222
177
|
return;
|
|
223
178
|
}
|
|
224
179
|
express.json({ limit: '20mb' })(req, res, next);
|
|
@@ -259,7 +214,7 @@ export async function startServer(options = {}) {
|
|
|
259
214
|
.catch(() => { });
|
|
260
215
|
}
|
|
261
216
|
if (channelId === GLOBAL_CHANNEL_ID) {
|
|
262
|
-
const snapshot =
|
|
217
|
+
const snapshot = activeRunsTracker.buildSnapshot();
|
|
263
218
|
ensureEventId(snapshot);
|
|
264
219
|
res.write(`data: ${JSON.stringify(snapshot)}\n\n`);
|
|
265
220
|
}
|
|
@@ -429,38 +384,19 @@ export async function startServer(options = {}) {
|
|
|
429
384
|
},
|
|
430
385
|
};
|
|
431
386
|
ensureEventId(stoppedEvent);
|
|
432
|
-
|
|
433
|
-
sendToClientKey(getClientKey(targetChannelId, targetThreadId), stoppedEvent);
|
|
434
|
-
sendToClientKey(GLOBAL_CHANNEL_ID, stoppedEvent);
|
|
387
|
+
broadcastLifecycleEvent(stoppedEvent, targetChannelId, targetThreadId, lifecycleDeps);
|
|
435
388
|
res.json({ success: stopped });
|
|
436
389
|
return;
|
|
437
390
|
}
|
|
438
|
-
const onEvent =
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
agentId: chunk.data.agentId,
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
else if (chunk.type === 'agent:run:end') {
|
|
451
|
-
activeRuns.delete(getRunKey(chunk.data.runId, chunk.data.agentId, chunk.data.channelId, chunk.data.threadId));
|
|
452
|
-
}
|
|
453
|
-
else if (chunk.type === 'agent:run:stopped') {
|
|
454
|
-
purgeActiveRunsForThread(chunk.data.channelId, chunk.data.threadId);
|
|
455
|
-
broadcastActiveRunsSnapshot();
|
|
456
|
-
}
|
|
457
|
-
sendToClientKey(targetClientKey, chunk);
|
|
458
|
-
if (chunk.type === 'agent:run:start' ||
|
|
459
|
-
chunk.type === 'agent:run:end' ||
|
|
460
|
-
chunk.type === 'agent:run:stopped') {
|
|
461
|
-
sendToClientKey(GLOBAL_CHANNEL_ID, chunk);
|
|
462
|
-
}
|
|
463
|
-
};
|
|
391
|
+
const onEvent = createRunEventHandler({
|
|
392
|
+
defaultChannelId: channelId,
|
|
393
|
+
defaultThreadId: threadId,
|
|
394
|
+
activeRuns: activeRunsTracker,
|
|
395
|
+
sendToClientKey,
|
|
396
|
+
getClientKey,
|
|
397
|
+
globalChannelId: GLOBAL_CHANNEL_ID,
|
|
398
|
+
onRunStopped: handleRunStopped,
|
|
399
|
+
});
|
|
464
400
|
try {
|
|
465
401
|
ensureEventId(event);
|
|
466
402
|
const isUserConversationStart = event.type === 'agent:invoke' &&
|
|
@@ -490,20 +426,16 @@ export async function startServer(options = {}) {
|
|
|
490
426
|
await runAgent({
|
|
491
427
|
runId,
|
|
492
428
|
agentId: target.agentId,
|
|
493
|
-
event
|
|
494
|
-
|
|
495
|
-
|
|
429
|
+
event: {
|
|
430
|
+
...event,
|
|
431
|
+
meta: { ...event.meta, channelId, threadId },
|
|
432
|
+
},
|
|
496
433
|
publicBaseUrl: resolvePublicBaseUrl(),
|
|
497
434
|
onEvent,
|
|
498
435
|
});
|
|
499
436
|
res.sendStatus(200);
|
|
500
437
|
}
|
|
501
438
|
catch (error) {
|
|
502
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
503
|
-
const isUnknownAgent = (error instanceof Error &&
|
|
504
|
-
(error.code === 'AGENT_NOT_FOUND' ||
|
|
505
|
-
error.message.includes('does not exist'))) ||
|
|
506
|
-
message.includes('does not exist');
|
|
507
439
|
console.error('[publish] Failed to dispatch event', {
|
|
508
440
|
runId,
|
|
509
441
|
channelId,
|
|
@@ -511,13 +443,80 @@ export async function startServer(options = {}) {
|
|
|
511
443
|
eventType: event.type,
|
|
512
444
|
error,
|
|
513
445
|
});
|
|
514
|
-
|
|
515
|
-
|
|
446
|
+
respondAgentDispatchError(res, error, 'Failed to process publish event');
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
const WEBHOOK_TIMEOUT_MS = Number(process.env.OPENBOT_WEBHOOK_TIMEOUT_MS ?? 30000);
|
|
450
|
+
const handleInboundWebhook = async (req, res) => {
|
|
451
|
+
const provider = req.params.provider?.trim();
|
|
452
|
+
if (!provider || !/^[a-z0-9][a-z0-9_-]*$/i.test(provider)) {
|
|
453
|
+
res.status(400).json({ error: 'Invalid webhook provider' });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
|
|
457
|
+
const runId = `run_${generateId()}`;
|
|
458
|
+
const agentId = await resolveWebhookAgentId(provider);
|
|
459
|
+
let httpResponse = null;
|
|
460
|
+
const onEvent = createRunEventHandler({
|
|
461
|
+
activeRuns: activeRunsTracker,
|
|
462
|
+
sendToClientKey,
|
|
463
|
+
getClientKey,
|
|
464
|
+
globalChannelId: GLOBAL_CHANNEL_ID,
|
|
465
|
+
onRunStopped: handleRunStopped,
|
|
466
|
+
onWebhookHttpResponse: (response) => {
|
|
467
|
+
httpResponse = response;
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
const event = {
|
|
471
|
+
type: 'action:webhook',
|
|
472
|
+
data: {
|
|
473
|
+
provider,
|
|
474
|
+
method: req.method,
|
|
475
|
+
rawBody: rawBody.toString('base64'),
|
|
476
|
+
headers: pickWebhookHeaders(req),
|
|
477
|
+
query: req.query,
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
try {
|
|
481
|
+
ensureEventId(event);
|
|
482
|
+
const runPromise = runAgent({
|
|
483
|
+
runId,
|
|
484
|
+
agentId,
|
|
485
|
+
event,
|
|
486
|
+
persistEvents: true,
|
|
487
|
+
publicBaseUrl: resolvePublicBaseUrl(),
|
|
488
|
+
onEvent,
|
|
489
|
+
});
|
|
490
|
+
let timeoutId;
|
|
491
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
492
|
+
timeoutId = setTimeout(() => reject(new Error('webhook_timeout')), WEBHOOK_TIMEOUT_MS);
|
|
493
|
+
});
|
|
494
|
+
try {
|
|
495
|
+
await Promise.race([runPromise, timeoutPromise]);
|
|
496
|
+
}
|
|
497
|
+
finally {
|
|
498
|
+
if (timeoutId)
|
|
499
|
+
clearTimeout(timeoutId);
|
|
500
|
+
}
|
|
501
|
+
sendWebhookHttpResponse(res, httpResponse ?? { status: 200, body: {} });
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
const isTimeout = error instanceof Error && error.message === 'webhook_timeout';
|
|
505
|
+
console.error('[webhook] Failed to process inbound webhook', {
|
|
506
|
+
provider,
|
|
507
|
+
method: req.method,
|
|
508
|
+
agentId,
|
|
509
|
+
error,
|
|
510
|
+
});
|
|
511
|
+
if (isTimeout) {
|
|
512
|
+
res.status(504).json({ error: 'Webhook handler timed out' });
|
|
516
513
|
return;
|
|
517
514
|
}
|
|
518
|
-
res
|
|
515
|
+
respondAgentDispatchError(res, error, 'Failed to process webhook');
|
|
519
516
|
}
|
|
520
|
-
}
|
|
517
|
+
};
|
|
518
|
+
app.get('/api/webhooks/:provider', handleInboundWebhook);
|
|
519
|
+
app.post('/api/webhooks/:provider', handleInboundWebhook);
|
|
521
520
|
app.get('/api/state', async (req, res) => {
|
|
522
521
|
let event;
|
|
523
522
|
try {
|
|
@@ -531,7 +530,7 @@ export async function startServer(options = {}) {
|
|
|
531
530
|
const { channelId, threadId, agentId, runId } = getContext(req);
|
|
532
531
|
// In-memory active runs (not persisted). Mirrors the initial SSE frame on __global__.
|
|
533
532
|
if (channelId === GLOBAL_CHANNEL_ID && event.type === 'action:storage:get-active-runs') {
|
|
534
|
-
const snapshot =
|
|
533
|
+
const snapshot = activeRunsTracker.buildSnapshot();
|
|
535
534
|
ensureEventId(snapshot);
|
|
536
535
|
res.json({ events: [snapshot] });
|
|
537
536
|
return;
|
|
@@ -572,9 +571,10 @@ export async function startServer(options = {}) {
|
|
|
572
571
|
await runAgent({
|
|
573
572
|
runId,
|
|
574
573
|
agentId: agentId || STATE_AGENT_ID,
|
|
575
|
-
event
|
|
576
|
-
|
|
577
|
-
|
|
574
|
+
event: {
|
|
575
|
+
...event,
|
|
576
|
+
meta: { ...event.meta, channelId, threadId },
|
|
577
|
+
},
|
|
578
578
|
persistEvents: false,
|
|
579
579
|
publicBaseUrl: resolvePublicBaseUrl(),
|
|
580
580
|
onEvent,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Webhook provider id for `POST /api/webhooks/slack`. */
|
|
2
|
+
export const SLACK_WEBHOOK_PROVIDER = 'slack';
|
|
3
|
+
const SLACK_CHANNEL_ID_PREFIX = 'slack-';
|
|
4
|
+
/** Map a Slack `channel` id to a stable OpenBot channel id. */
|
|
5
|
+
export function slackChannelToOpenBotChannelId(slackChannelId) {
|
|
6
|
+
const normalized = slackChannelId.trim();
|
|
7
|
+
if (!normalized) {
|
|
8
|
+
throw new Error('slackChannelId is required');
|
|
9
|
+
}
|
|
10
|
+
return `${SLACK_CHANNEL_ID_PREFIX}${normalized}`;
|
|
11
|
+
}
|
|
12
|
+
const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
13
|
+
const readString = (value) => {
|
|
14
|
+
if (typeof value !== 'string')
|
|
15
|
+
return undefined;
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
return trimmed || undefined;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Best-effort parse of a Slack Events API payload for routing and plugin handling.
|
|
21
|
+
* Returns `null` when the body is not JSON or not a recognized Slack envelope.
|
|
22
|
+
*/
|
|
23
|
+
export function parseSlackWebhookPayload(rawBody) {
|
|
24
|
+
if (rawBody.length === 0)
|
|
25
|
+
return null;
|
|
26
|
+
let payload;
|
|
27
|
+
try {
|
|
28
|
+
payload = JSON.parse(rawBody.toString('utf-8'));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (!isRecord(payload))
|
|
34
|
+
return null;
|
|
35
|
+
const envelopeType = readString(payload.type);
|
|
36
|
+
if (envelopeType === 'url_verification') {
|
|
37
|
+
const challenge = readString(payload.challenge);
|
|
38
|
+
if (!challenge)
|
|
39
|
+
return null;
|
|
40
|
+
return { kind: 'url_verification', challenge };
|
|
41
|
+
}
|
|
42
|
+
if (envelopeType !== 'event_callback')
|
|
43
|
+
return null;
|
|
44
|
+
const event = payload.event;
|
|
45
|
+
if (!isRecord(event))
|
|
46
|
+
return null;
|
|
47
|
+
const eventType = readString(event.type);
|
|
48
|
+
if (eventType !== 'message') {
|
|
49
|
+
return { kind: 'ignored', reason: `unsupported_event_type:${eventType || 'unknown'}` };
|
|
50
|
+
}
|
|
51
|
+
const subtype = readString(event.subtype);
|
|
52
|
+
if (subtype && subtype !== 'file_share') {
|
|
53
|
+
return { kind: 'ignored', reason: `message_subtype:${subtype}` };
|
|
54
|
+
}
|
|
55
|
+
if (readString(event.bot_id) || readString(event.bot_profile)) {
|
|
56
|
+
return { kind: 'ignored', reason: 'bot_message' };
|
|
57
|
+
}
|
|
58
|
+
const slackChannelId = readString(event.channel);
|
|
59
|
+
if (!slackChannelId) {
|
|
60
|
+
return { kind: 'ignored', reason: 'missing_channel' };
|
|
61
|
+
}
|
|
62
|
+
const text = readString(event.text) || (subtype === 'file_share' ? '[file shared]' : undefined);
|
|
63
|
+
if (!text) {
|
|
64
|
+
return { kind: 'ignored', reason: 'empty_text' };
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
kind: 'message',
|
|
68
|
+
slackChannelId,
|
|
69
|
+
threadTs: readString(event.thread_ts),
|
|
70
|
+
messageTs: readString(event.ts),
|
|
71
|
+
userId: readString(event.user),
|
|
72
|
+
text,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Resolve the OpenBot thread id for a Slack message (`thread_ts` or root `ts`). */
|
|
76
|
+
export function slackMessageToOpenBotThreadId(message) {
|
|
77
|
+
return message.threadTs || message.messageTs;
|
|
78
|
+
}
|
|
79
|
+
export function slackChannelSpec(slackChannelId) {
|
|
80
|
+
return `# Slack ${slackChannelId}\n\nInbound messages from Slack channel \`${slackChannelId}\`.`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ORCHESTRATOR_AGENT_ID } from '../harness/index.js';
|
|
2
|
+
import { storageService } from '../plugins/storage/service.js';
|
|
3
|
+
/** Default agent when no `agents/<provider>/AGENT.md` exists. */
|
|
4
|
+
export const WEBHOOK_AGENT_FALLBACK_ID = ORCHESTRATOR_AGENT_ID;
|
|
5
|
+
export const isAgentNotFound = (error) => error instanceof Error &&
|
|
6
|
+
(error.code === 'AGENT_NOT_FOUND' ||
|
|
7
|
+
error.message.includes('does not exist'));
|
|
8
|
+
export function respondAgentDispatchError(res, error, fallbackMessage) {
|
|
9
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
10
|
+
if (isAgentNotFound(error) || message.includes('does not exist')) {
|
|
11
|
+
res.status(400).json({ error: message });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
res.status(500).json({ error: fallbackMessage });
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Routes `GET`/`POST /api/webhooks/:provider` to agent `<provider>` when that agent exists,
|
|
18
|
+
* otherwise falls back to {@link WEBHOOK_AGENT_FALLBACK_ID}.
|
|
19
|
+
*/
|
|
20
|
+
export async function resolveWebhookAgentId(provider) {
|
|
21
|
+
try {
|
|
22
|
+
await storageService.getAgentDetails({ agentId: provider });
|
|
23
|
+
return provider;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (isAgentNotFound(error)) {
|
|
27
|
+
return WEBHOOK_AGENT_FALLBACK_ID;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Copy request headers for plugin-side signature verification. */
|
|
33
|
+
export function pickWebhookHeaders(req) {
|
|
34
|
+
return { ...req.headers };
|
|
35
|
+
}
|
|
36
|
+
export function sendWebhookHttpResponse(res, out) {
|
|
37
|
+
if (out.headers) {
|
|
38
|
+
for (const [key, value] of Object.entries(out.headers)) {
|
|
39
|
+
res.setHeader(key, value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const status = out.status;
|
|
43
|
+
if (out.body === undefined) {
|
|
44
|
+
res.sendStatus(status);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (typeof out.body === 'string') {
|
|
48
|
+
res.status(status).send(out.body);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
res.status(status).json(out.body);
|
|
52
|
+
}
|