@owloops/browserbird 1.0.1 → 1.0.3
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/bin/browserbird +7 -1
- package/dist/db-BsYEYsul.mjs +1011 -0
- package/dist/index.mjs +4748 -0
- package/package.json +6 -3
- package/src/channel/blocks.ts +0 -485
- package/src/channel/coalesce.ts +0 -79
- package/src/channel/commands.ts +0 -216
- package/src/channel/handler.ts +0 -272
- package/src/channel/slack.ts +0 -573
- package/src/channel/types.ts +0 -59
- package/src/cli/banner.ts +0 -10
- package/src/cli/birds.ts +0 -396
- package/src/cli/config.ts +0 -77
- package/src/cli/doctor.ts +0 -63
- package/src/cli/index.ts +0 -5
- package/src/cli/jobs.ts +0 -166
- package/src/cli/logs.ts +0 -67
- package/src/cli/run.ts +0 -148
- package/src/cli/sessions.ts +0 -158
- package/src/cli/style.ts +0 -19
- package/src/config.ts +0 -291
- package/src/core/logger.ts +0 -78
- package/src/core/redact.ts +0 -75
- package/src/core/types.ts +0 -83
- package/src/core/uid.ts +0 -26
- package/src/core/utils.ts +0 -137
- package/src/cron/parse.ts +0 -146
- package/src/cron/scheduler.ts +0 -242
- package/src/daemon.ts +0 -169
- package/src/db/auth.ts +0 -49
- package/src/db/birds.ts +0 -357
- package/src/db/core.ts +0 -377
- package/src/db/index.ts +0 -10
- package/src/db/jobs.ts +0 -289
- package/src/db/logs.ts +0 -64
- package/src/db/messages.ts +0 -79
- package/src/db/path.ts +0 -30
- package/src/db/sessions.ts +0 -165
- package/src/jobs.ts +0 -140
- package/src/provider/claude.test.ts +0 -95
- package/src/provider/claude.ts +0 -196
- package/src/provider/opencode.test.ts +0 -169
- package/src/provider/opencode.ts +0 -248
- package/src/provider/session.ts +0 -65
- package/src/provider/spawn.ts +0 -173
- package/src/provider/stream.ts +0 -67
- package/src/provider/types.ts +0 -24
- package/src/server/auth.ts +0 -135
- package/src/server/health.ts +0 -87
- package/src/server/http.ts +0 -132
- package/src/server/index.ts +0 -6
- package/src/server/lifecycle.ts +0 -135
- package/src/server/routes.ts +0 -1199
- package/src/server/sse.ts +0 -54
- package/src/server/static.ts +0 -45
- package/src/server/vnc-proxy.ts +0 -75
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owloops/browserbird",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Self-hosted AI agent for Slack with a real browser, a scheduler, and a web dashboard",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"node": ">=22.21.0"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
|
-
"
|
|
13
|
+
"dist",
|
|
14
14
|
"bin",
|
|
15
15
|
"web/dist",
|
|
16
16
|
"README.md"
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
"format": "prettier --write src/",
|
|
23
23
|
"format:check": "prettier --check src/",
|
|
24
24
|
"test": "node --test src/**/*.test.ts",
|
|
25
|
+
"build": "tsdown",
|
|
26
|
+
"dev": "tsdown && ./bin/browserbird",
|
|
25
27
|
"start": "./bin/browserbird",
|
|
26
|
-
"prepublishOnly": "npm run lint && npm run format:check && npm run typecheck && cd web && npm ci && npm run build"
|
|
28
|
+
"prepublishOnly": "npm run lint && npm run format:check && npm run typecheck && npm run build && cd web && npm ci && npm run build"
|
|
27
29
|
},
|
|
28
30
|
"keywords": [
|
|
29
31
|
"slack",
|
|
@@ -56,6 +58,7 @@
|
|
|
56
58
|
"prettier": "^3.8.1",
|
|
57
59
|
"prettier-plugin-svelte": "^3.5.0",
|
|
58
60
|
"semantic-release": "^25.0.3",
|
|
61
|
+
"tsdown": "^0.21.0-beta.2",
|
|
59
62
|
"typescript": "^5.8.0"
|
|
60
63
|
},
|
|
61
64
|
"publishConfig": {
|
package/src/channel/blocks.ts
DELETED
|
@@ -1,485 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Slack Block Kit builder functions for rich message formatting. */
|
|
2
|
-
|
|
3
|
-
import type { StreamEventCompletion } from '../provider/stream.ts';
|
|
4
|
-
import { formatDuration } from '../core/utils.ts';
|
|
5
|
-
import { shortUid } from '../core/uid.ts';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Slack Block Kit block types used throughout the channel layer.
|
|
9
|
-
* Intentionally minimal, only the shapes we actually construct.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
interface PlainText {
|
|
13
|
-
type: 'plain_text';
|
|
14
|
-
text: string;
|
|
15
|
-
emoji?: boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface MrkdwnText {
|
|
19
|
-
type: 'mrkdwn';
|
|
20
|
-
text: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
type TextObject = PlainText | MrkdwnText;
|
|
24
|
-
|
|
25
|
-
interface HeaderBlock {
|
|
26
|
-
type: 'header';
|
|
27
|
-
text: PlainText;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface SectionBlock {
|
|
31
|
-
type: 'section';
|
|
32
|
-
text?: MrkdwnText;
|
|
33
|
-
fields?: MrkdwnText[];
|
|
34
|
-
accessory?: OverflowElement | ButtonElement;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface DividerBlock {
|
|
38
|
-
type: 'divider';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface ContextBlock {
|
|
42
|
-
type: 'context';
|
|
43
|
-
elements: TextObject[];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface ActionsBlock {
|
|
47
|
-
type: 'actions';
|
|
48
|
-
block_id?: string;
|
|
49
|
-
elements: ActionElement[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
interface InputBlock {
|
|
53
|
-
type: 'input';
|
|
54
|
-
block_id: string;
|
|
55
|
-
label: PlainText;
|
|
56
|
-
element: InputElement;
|
|
57
|
-
optional?: boolean;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface ButtonElement {
|
|
61
|
-
type: 'button';
|
|
62
|
-
text: PlainText;
|
|
63
|
-
action_id: string;
|
|
64
|
-
value?: string;
|
|
65
|
-
url?: string;
|
|
66
|
-
style?: 'primary' | 'danger';
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
interface OverflowElement {
|
|
70
|
-
type: 'overflow';
|
|
71
|
-
action_id: string;
|
|
72
|
-
options: OverflowOption[];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
interface OverflowOption {
|
|
76
|
-
text: PlainText;
|
|
77
|
-
value: string;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
interface PlainTextInputElement {
|
|
81
|
-
type: 'plain_text_input';
|
|
82
|
-
action_id: string;
|
|
83
|
-
placeholder?: PlainText;
|
|
84
|
-
multiline?: boolean;
|
|
85
|
-
initial_value?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
interface StaticSelectElement {
|
|
89
|
-
type: 'static_select';
|
|
90
|
-
action_id: string;
|
|
91
|
-
placeholder?: PlainText;
|
|
92
|
-
options: SelectOption[];
|
|
93
|
-
initial_option?: SelectOption;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
interface ConversationsSelectElement {
|
|
97
|
-
type: 'conversations_select';
|
|
98
|
-
action_id: string;
|
|
99
|
-
default_to_current_conversation?: boolean;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
interface RadioButtonsElement {
|
|
103
|
-
type: 'radio_buttons';
|
|
104
|
-
action_id: string;
|
|
105
|
-
options: SelectOption[];
|
|
106
|
-
initial_option?: SelectOption;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
interface SelectOption {
|
|
110
|
-
text: PlainText;
|
|
111
|
-
value: string;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
type ActionElement = ButtonElement | OverflowElement | StaticSelectElement;
|
|
115
|
-
type InputElement =
|
|
116
|
-
| PlainTextInputElement
|
|
117
|
-
| StaticSelectElement
|
|
118
|
-
| ConversationsSelectElement
|
|
119
|
-
| RadioButtonsElement;
|
|
120
|
-
|
|
121
|
-
export type Block =
|
|
122
|
-
| HeaderBlock
|
|
123
|
-
| SectionBlock
|
|
124
|
-
| DividerBlock
|
|
125
|
-
| ContextBlock
|
|
126
|
-
| ActionsBlock
|
|
127
|
-
| InputBlock;
|
|
128
|
-
|
|
129
|
-
export interface ModalView {
|
|
130
|
-
type: 'modal';
|
|
131
|
-
callback_id: string;
|
|
132
|
-
title: PlainText;
|
|
133
|
-
submit?: PlainText;
|
|
134
|
-
close?: PlainText;
|
|
135
|
-
private_metadata?: string;
|
|
136
|
-
blocks: Block[];
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function plain(text: string): PlainText {
|
|
140
|
-
return { type: 'plain_text', text, emoji: true };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function mrkdwn(text: string): MrkdwnText {
|
|
144
|
-
return { type: 'mrkdwn', text };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function header(text: string): HeaderBlock {
|
|
148
|
-
return { type: 'header', text: plain(text) };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function section(text: string): SectionBlock {
|
|
152
|
-
return { type: 'section', text: mrkdwn(text) };
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function fields(...pairs: [string, string][]): SectionBlock {
|
|
156
|
-
return {
|
|
157
|
-
type: 'section',
|
|
158
|
-
fields: pairs.map(([label, value]) => mrkdwn(`*${label}:*\n${value}`)),
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function divider(): DividerBlock {
|
|
163
|
-
return { type: 'divider' };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function context(text: string): ContextBlock {
|
|
167
|
-
return { type: 'context', elements: [mrkdwn(text)] };
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function formatCost(usd: number): string {
|
|
171
|
-
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
|
172
|
-
return `$${usd.toFixed(2)}`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const SUBTYPE_LABELS: Record<string, string> = {
|
|
176
|
-
error_max_turns: 'Warning: Hit turn limit',
|
|
177
|
-
error_max_budget_usd: 'Warning: Hit budget limit',
|
|
178
|
-
error_during_execution: 'Error during execution',
|
|
179
|
-
error_max_structured_output_retries: 'Structured output failed',
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Builds a context footer appended to the streaming message on completion.
|
|
184
|
-
* Uses only non-section blocks (divider + context) so Slack renders the
|
|
185
|
-
* response text from the top-level `text` field, avoiding the 3000-char
|
|
186
|
-
* section limit for long agent responses.
|
|
187
|
-
*/
|
|
188
|
-
export function completionFooterBlocks(
|
|
189
|
-
completion: StreamEventCompletion,
|
|
190
|
-
hasError: boolean,
|
|
191
|
-
birdName?: string,
|
|
192
|
-
userId?: string,
|
|
193
|
-
): Block[] {
|
|
194
|
-
const parts: string[] = [];
|
|
195
|
-
|
|
196
|
-
const subtypeLabel = SUBTYPE_LABELS[completion.subtype];
|
|
197
|
-
if (subtypeLabel) parts.push(subtypeLabel);
|
|
198
|
-
if (hasError) parts.push('Error');
|
|
199
|
-
|
|
200
|
-
if (userId) parts.push(`Requested by <@${userId}>`);
|
|
201
|
-
parts.push(formatDuration(completion.durationMs));
|
|
202
|
-
parts.push(`${completion.numTurns} turn${completion.numTurns === 1 ? '' : 's'}`);
|
|
203
|
-
if (birdName) parts.push(birdName);
|
|
204
|
-
|
|
205
|
-
return [divider(), context(parts.join(' | '))];
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Standalone completion card for cron/bird results posted to a channel
|
|
210
|
-
* (not in a streaming thread; these need full context).
|
|
211
|
-
*/
|
|
212
|
-
export function sessionCompleteBlocks(
|
|
213
|
-
completion: StreamEventCompletion,
|
|
214
|
-
summary: string,
|
|
215
|
-
birdName?: string,
|
|
216
|
-
userId?: string,
|
|
217
|
-
): Block[] {
|
|
218
|
-
const subtypeLabel = SUBTYPE_LABELS[completion.subtype];
|
|
219
|
-
const statusText = subtypeLabel ?? 'Success';
|
|
220
|
-
const headerText = completion.subtype === 'success' ? 'Session Complete' : 'Session Ended';
|
|
221
|
-
|
|
222
|
-
const blocks: Block[] = [
|
|
223
|
-
header(headerText),
|
|
224
|
-
fields(
|
|
225
|
-
['Status', statusText],
|
|
226
|
-
['Duration', formatDuration(completion.durationMs)],
|
|
227
|
-
['Turns', String(completion.numTurns)],
|
|
228
|
-
),
|
|
229
|
-
];
|
|
230
|
-
|
|
231
|
-
if (summary) {
|
|
232
|
-
const trimmed = summary.length > 2800 ? summary.slice(0, 2800) + '...' : summary;
|
|
233
|
-
blocks.push(section(`*Summary:*\n${trimmed}`));
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const contextParts: string[] = [];
|
|
237
|
-
if (birdName) contextParts.push(`Bird: *${birdName}*`);
|
|
238
|
-
if (userId) contextParts.push(`Triggered by <@${userId}>`);
|
|
239
|
-
contextParts.push(
|
|
240
|
-
`${completion.tokensIn.toLocaleString()} in / ${completion.tokensOut.toLocaleString()} out tokens`,
|
|
241
|
-
);
|
|
242
|
-
blocks.push(context(contextParts.join(' | ')));
|
|
243
|
-
|
|
244
|
-
return blocks;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export function sessionErrorBlocks(
|
|
248
|
-
errorMessage: string,
|
|
249
|
-
opts?: {
|
|
250
|
-
sessionUid?: string;
|
|
251
|
-
birdName?: string;
|
|
252
|
-
durationMs?: number;
|
|
253
|
-
},
|
|
254
|
-
): Block[] {
|
|
255
|
-
const blocks: Block[] = [header('Session Failed')];
|
|
256
|
-
|
|
257
|
-
const sectionBlock: SectionBlock = {
|
|
258
|
-
type: 'section',
|
|
259
|
-
text: mrkdwn(`*Error: ${truncate(errorMessage, 200)}*`),
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
if (opts?.sessionUid) {
|
|
263
|
-
sectionBlock.accessory = {
|
|
264
|
-
type: 'overflow',
|
|
265
|
-
action_id: 'session_error_overflow',
|
|
266
|
-
options: [
|
|
267
|
-
{ text: plain('Retry'), value: `retry:${opts.sessionUid}` },
|
|
268
|
-
{ text: plain('View Logs'), value: `logs:${opts.sessionUid}` },
|
|
269
|
-
],
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
blocks.push(sectionBlock);
|
|
274
|
-
|
|
275
|
-
const fieldPairs: [string, string][] = [];
|
|
276
|
-
if (opts?.birdName) fieldPairs.push(['Bird', opts.birdName]);
|
|
277
|
-
if (opts?.durationMs) fieldPairs.push(['Duration', formatDuration(opts.durationMs)]);
|
|
278
|
-
if (fieldPairs.length > 0) blocks.push(fields(...fieldPairs));
|
|
279
|
-
|
|
280
|
-
return blocks;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
export function busyBlocks(activeCount: number, maxConcurrent: number): Block[] {
|
|
284
|
-
return [
|
|
285
|
-
section('*Too many active sessions*'),
|
|
286
|
-
context(`${activeCount}/${maxConcurrent} slots in use. Try again shortly.`),
|
|
287
|
-
];
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export function noAgentBlocks(channelId: string): Block[] {
|
|
291
|
-
return [section('*No agent configured for this channel*'), context(`Channel: \`${channelId}\``)];
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export function birdCreateModal(defaults?: {
|
|
295
|
-
name?: string;
|
|
296
|
-
schedule?: string;
|
|
297
|
-
prompt?: string;
|
|
298
|
-
}): ModalView {
|
|
299
|
-
const scheduleOptions: SelectOption[] = [
|
|
300
|
-
{ text: plain('Every hour'), value: '0 * * * *' },
|
|
301
|
-
{ text: plain('Every 6 hours'), value: '0 */6 * * *' },
|
|
302
|
-
{ text: plain('Daily at midnight'), value: '0 0 * * *' },
|
|
303
|
-
{ text: plain('Weekly on Monday'), value: '0 0 * * 1' },
|
|
304
|
-
];
|
|
305
|
-
|
|
306
|
-
const initialSchedule = defaults?.schedule
|
|
307
|
-
? scheduleOptions.find((o) => o.value === defaults.schedule)
|
|
308
|
-
: undefined;
|
|
309
|
-
|
|
310
|
-
return {
|
|
311
|
-
type: 'modal',
|
|
312
|
-
callback_id: 'bird_create',
|
|
313
|
-
title: plain('Create Bird'),
|
|
314
|
-
submit: plain('Create'),
|
|
315
|
-
close: plain('Cancel'),
|
|
316
|
-
blocks: [
|
|
317
|
-
{
|
|
318
|
-
type: 'input',
|
|
319
|
-
block_id: 'bird_name',
|
|
320
|
-
label: plain('Name'),
|
|
321
|
-
element: {
|
|
322
|
-
type: 'plain_text_input',
|
|
323
|
-
action_id: 'name_input',
|
|
324
|
-
placeholder: plain('e.g., lint-patrol'),
|
|
325
|
-
...(defaults?.name ? { initial_value: defaults.name } : {}),
|
|
326
|
-
},
|
|
327
|
-
},
|
|
328
|
-
{
|
|
329
|
-
type: 'input',
|
|
330
|
-
block_id: 'bird_schedule',
|
|
331
|
-
label: plain('Schedule'),
|
|
332
|
-
element: {
|
|
333
|
-
type: 'static_select',
|
|
334
|
-
action_id: 'schedule_select',
|
|
335
|
-
placeholder: plain('Choose a schedule'),
|
|
336
|
-
options: scheduleOptions,
|
|
337
|
-
...(initialSchedule ? { initial_option: initialSchedule } : {}),
|
|
338
|
-
},
|
|
339
|
-
},
|
|
340
|
-
{
|
|
341
|
-
type: 'input',
|
|
342
|
-
block_id: 'bird_prompt',
|
|
343
|
-
label: plain('Prompt'),
|
|
344
|
-
element: {
|
|
345
|
-
type: 'plain_text_input',
|
|
346
|
-
action_id: 'prompt_input',
|
|
347
|
-
multiline: true,
|
|
348
|
-
placeholder: plain('What should this bird do?'),
|
|
349
|
-
...(defaults?.prompt ? { initial_value: defaults.prompt } : {}),
|
|
350
|
-
},
|
|
351
|
-
},
|
|
352
|
-
{
|
|
353
|
-
type: 'input',
|
|
354
|
-
block_id: 'bird_channel',
|
|
355
|
-
label: plain('Report to Channel'),
|
|
356
|
-
element: {
|
|
357
|
-
type: 'conversations_select',
|
|
358
|
-
action_id: 'channel_select',
|
|
359
|
-
default_to_current_conversation: true,
|
|
360
|
-
},
|
|
361
|
-
},
|
|
362
|
-
{
|
|
363
|
-
type: 'input',
|
|
364
|
-
block_id: 'bird_enabled',
|
|
365
|
-
label: plain('Status'),
|
|
366
|
-
element: {
|
|
367
|
-
type: 'radio_buttons',
|
|
368
|
-
action_id: 'enabled_radio',
|
|
369
|
-
options: [
|
|
370
|
-
{ text: plain('Enabled'), value: 'enabled' },
|
|
371
|
-
{ text: plain('Disabled'), value: 'disabled' },
|
|
372
|
-
],
|
|
373
|
-
initial_option: { text: plain('Enabled'), value: 'enabled' },
|
|
374
|
-
},
|
|
375
|
-
},
|
|
376
|
-
],
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
export function birdListBlocks(
|
|
381
|
-
birds: Array<{
|
|
382
|
-
uid: string;
|
|
383
|
-
name: string;
|
|
384
|
-
schedule: string;
|
|
385
|
-
enabled: boolean;
|
|
386
|
-
lastStatus: string | null;
|
|
387
|
-
agentId: string;
|
|
388
|
-
}>,
|
|
389
|
-
): Block[] {
|
|
390
|
-
if (birds.length === 0) {
|
|
391
|
-
return [
|
|
392
|
-
section('*No birds configured*'),
|
|
393
|
-
context('Use `/bird create` to create your first bird.'),
|
|
394
|
-
];
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const blocks: Block[] = [header('Active Birds')];
|
|
398
|
-
|
|
399
|
-
for (const bird of birds) {
|
|
400
|
-
const status = bird.enabled ? '[on]' : '[off]';
|
|
401
|
-
const lastRun = bird.lastStatus ?? 'never';
|
|
402
|
-
blocks.push(
|
|
403
|
-
section(
|
|
404
|
-
`${status} *${bird.name}*\n\`${bird.schedule}\` | Agent: \`${bird.agentId}\` | Last: ${lastRun}`,
|
|
405
|
-
),
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
blocks.push(context(`${birds.length} bird${birds.length === 1 ? '' : 's'} total`));
|
|
410
|
-
|
|
411
|
-
return blocks;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
export function birdLogsBlocks(
|
|
415
|
-
birdName: string,
|
|
416
|
-
flights: Array<{
|
|
417
|
-
uid: string;
|
|
418
|
-
status: string;
|
|
419
|
-
startedAt: string;
|
|
420
|
-
durationMs?: number;
|
|
421
|
-
error?: string;
|
|
422
|
-
}>,
|
|
423
|
-
): Block[] {
|
|
424
|
-
if (flights.length === 0) {
|
|
425
|
-
return [
|
|
426
|
-
section(`*${birdName}* - No flights yet`),
|
|
427
|
-
context('Trigger with `/bird fly ${birdName}`'),
|
|
428
|
-
];
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const blocks: Block[] = [header(`Flights: ${birdName}`)];
|
|
432
|
-
|
|
433
|
-
const lines = flights.map((f) => {
|
|
434
|
-
const icon = f.status === 'success' ? '[ok]' : f.status === 'running' ? '[...]' : '[err]';
|
|
435
|
-
const duration = f.durationMs ? formatDuration(f.durationMs) : '-';
|
|
436
|
-
const age = formatAge(f.startedAt);
|
|
437
|
-
const detail = f.error ? truncate(f.error, 80) : duration;
|
|
438
|
-
return `${icon} ${shortUid(f.uid)} \`${detail}\` - ${age} ago`;
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
blocks.push(section(lines.join('\n')));
|
|
442
|
-
blocks.push(context(`${flights.length} most recent flight${flights.length === 1 ? '' : 's'}`));
|
|
443
|
-
|
|
444
|
-
return blocks;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function formatAge(isoDate: string): string {
|
|
448
|
-
const normalized = isoDate.endsWith('Z') ? isoDate : isoDate + 'Z';
|
|
449
|
-
const ms = Date.now() - new Date(normalized).getTime();
|
|
450
|
-
const minutes = Math.floor(ms / 60_000);
|
|
451
|
-
if (minutes < 60) return `${minutes}m`;
|
|
452
|
-
const hours = Math.floor(minutes / 60);
|
|
453
|
-
if (hours < 24) return `${hours}h`;
|
|
454
|
-
const days = Math.floor(hours / 24);
|
|
455
|
-
return `${days}d`;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
export function birdFlyBlocks(birdName: string, userId: string): Block[] {
|
|
459
|
-
return [section(`*${birdName}* is taking flight...`), context(`Triggered by <@${userId}>`)];
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
export function statusBlocks(opts: {
|
|
463
|
-
slackConnected: boolean;
|
|
464
|
-
activeCount: number;
|
|
465
|
-
maxConcurrent: number;
|
|
466
|
-
birdCount: number;
|
|
467
|
-
uptime: string;
|
|
468
|
-
}): Block[] {
|
|
469
|
-
const slackStatus = opts.slackConnected ? 'Connected' : 'Disconnected';
|
|
470
|
-
|
|
471
|
-
return [
|
|
472
|
-
header('BrowserBird Status'),
|
|
473
|
-
fields(
|
|
474
|
-
['Slack', slackStatus],
|
|
475
|
-
['Active Sessions', `${opts.activeCount}/${opts.maxConcurrent}`],
|
|
476
|
-
['Birds', String(opts.birdCount)],
|
|
477
|
-
['Uptime', opts.uptime],
|
|
478
|
-
),
|
|
479
|
-
];
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
function truncate(text: string, maxLength: number): string {
|
|
483
|
-
if (text.length <= maxLength) return text;
|
|
484
|
-
return text.slice(0, maxLength) + '...';
|
|
485
|
-
}
|
package/src/channel/coalesce.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/** @fileoverview Debounces rapid-fire messages in group channels into single dispatches. */
|
|
2
|
-
|
|
3
|
-
export interface CoalescedMessage {
|
|
4
|
-
userId: string;
|
|
5
|
-
text: string;
|
|
6
|
-
timestamp: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface CoalesceDispatch {
|
|
10
|
-
channelId: string;
|
|
11
|
-
threadTs: string;
|
|
12
|
-
messages: CoalescedMessage[];
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export type DispatchFn = (dispatch: CoalesceDispatch) => void;
|
|
16
|
-
|
|
17
|
-
interface PendingBatch {
|
|
18
|
-
messages: CoalescedMessage[];
|
|
19
|
-
timer: ReturnType<typeof setTimeout>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface Coalescer {
|
|
23
|
-
push(channelId: string, threadTs: string, userId: string, text: string, messageTs: string): void;
|
|
24
|
-
flush(): void;
|
|
25
|
-
destroy(): void;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function createCoalescer(config: { debounceMs: number }, onDispatch: DispatchFn): Coalescer {
|
|
29
|
-
const pending = new Map<string, PendingBatch>();
|
|
30
|
-
|
|
31
|
-
function fire(key: string): void {
|
|
32
|
-
const batch = pending.get(key);
|
|
33
|
-
if (!batch) return;
|
|
34
|
-
pending.delete(key);
|
|
35
|
-
|
|
36
|
-
const [channelId, threadTs] = key.split(':') as [string, string];
|
|
37
|
-
onDispatch({ channelId, threadTs, messages: batch.messages });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function push(
|
|
41
|
-
channelId: string,
|
|
42
|
-
threadTs: string,
|
|
43
|
-
userId: string,
|
|
44
|
-
text: string,
|
|
45
|
-
messageTs: string,
|
|
46
|
-
): void {
|
|
47
|
-
const key = `${channelId}:${threadTs}`;
|
|
48
|
-
const message: CoalescedMessage = { userId, text, timestamp: messageTs };
|
|
49
|
-
|
|
50
|
-
const existing = pending.get(key);
|
|
51
|
-
if (existing) {
|
|
52
|
-
clearTimeout(existing.timer);
|
|
53
|
-
existing.messages.push(message);
|
|
54
|
-
existing.timer = setTimeout(() => fire(key), config.debounceMs);
|
|
55
|
-
} else {
|
|
56
|
-
const timer = setTimeout(() => fire(key), config.debounceMs);
|
|
57
|
-
pending.set(key, { messages: [message], timer });
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function flush(): void {
|
|
62
|
-
for (const key of [...pending.keys()]) {
|
|
63
|
-
const batch = pending.get(key);
|
|
64
|
-
if (batch) {
|
|
65
|
-
clearTimeout(batch.timer);
|
|
66
|
-
}
|
|
67
|
-
fire(key);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function destroy(): void {
|
|
72
|
-
for (const batch of pending.values()) {
|
|
73
|
-
clearTimeout(batch.timer);
|
|
74
|
-
}
|
|
75
|
-
pending.clear();
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return { push, flush, destroy };
|
|
79
|
-
}
|