@worca/ui 0.9.0 → 0.10.0

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.
Files changed (33) hide show
  1. package/app/main.bundle.js +900 -803
  2. package/app/main.bundle.js.map +4 -4
  3. package/app/styles.css +210 -8
  4. package/app/utils/state-actions.js +55 -0
  5. package/package.json +6 -4
  6. package/server/app.js +291 -6
  7. package/server/beads-reader.js +1 -1
  8. package/server/dispatch-external.js +106 -0
  9. package/server/ensure-webhook.js +66 -0
  10. package/server/index.js +22 -0
  11. package/server/integrations/adapter.js +91 -0
  12. package/server/integrations/adapters/discord.js +109 -0
  13. package/server/integrations/adapters/slack.js +106 -0
  14. package/server/integrations/adapters/telegram.js +231 -0
  15. package/server/integrations/adapters/webhook_out.js +253 -0
  16. package/server/integrations/allowlist.js +19 -0
  17. package/server/integrations/chat_context.js +68 -0
  18. package/server/integrations/commands/control.js +120 -0
  19. package/server/integrations/commands/global.js +239 -0
  20. package/server/integrations/commands/parser.js +29 -0
  21. package/server/integrations/commands/project.js +394 -0
  22. package/server/integrations/config-loader.js +40 -0
  23. package/server/integrations/index.js +390 -0
  24. package/server/integrations/markdown.js +220 -0
  25. package/server/integrations/rate_limiter.js +131 -0
  26. package/server/integrations/renderers.js +191 -0
  27. package/server/integrations/rest_client.js +17 -0
  28. package/server/integrations/verify.js +23 -0
  29. package/server/process-manager.js +212 -14
  30. package/server/project-routes.js +210 -44
  31. package/server/settings-validator.js +250 -0
  32. package/server/ws-beads-watcher.js +22 -6
  33. package/server/ws-message-router.js +1 -1
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Discord adapter — outbound only, REST POST /channels/{id}/messages (markdown).
3
+ * @module adapters/discord
4
+ */
5
+
6
+ const DISCORD_API = 'https://discord.com/api/v10';
7
+ const SEND_BACKOFF_DELAYS = [1000, 5000, 30000];
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Markdown renderer
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Render a NormalizedMessage to Discord markdown.
15
+ * @param {import('../adapter.js').NormalizedMessage} msg
16
+ * @returns {string}
17
+ */
18
+ export function renderToMarkdown(msg) {
19
+ const parts = [];
20
+ if (msg.title) {
21
+ parts.push(`**${msg.title}**\n`);
22
+ }
23
+ for (const seg of msg.body) {
24
+ switch (seg.kind) {
25
+ case 'markdown':
26
+ parts.push(seg.value);
27
+ break;
28
+ case 'bold':
29
+ parts.push(`**${seg.value}**`);
30
+ break;
31
+ case 'code':
32
+ parts.push(`\`${seg.value}\``);
33
+ break;
34
+ case 'code_block':
35
+ parts.push(`\`\`\`\n${seg.value}\n\`\`\``);
36
+ break;
37
+ case 'link':
38
+ parts.push(`[${seg.value}](${seg.href ?? ''})`);
39
+ break;
40
+ default: // 'text'
41
+ parts.push(seg.value);
42
+ }
43
+ }
44
+ return parts.join('');
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Adapter factory
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * @param {{
53
+ * botToken: string,
54
+ * channelId?: string,
55
+ * fetchFn?: typeof fetch,
56
+ * _sleep?: (ms: number) => Promise<void>
57
+ * }} options
58
+ * @returns {import('../adapter.js').ChatAdapter}
59
+ */
60
+ export function createDiscordAdapter({
61
+ botToken,
62
+ channelId: _defaultChannelId,
63
+ fetchFn = globalThis.fetch,
64
+ _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
65
+ }) {
66
+ const authHeader = botToken.startsWith('Bot ') ? botToken : `Bot ${botToken}`;
67
+
68
+ return {
69
+ name: 'discord',
70
+ supportsInbound: false,
71
+ persistent: false,
72
+
73
+ connectionState() {
74
+ return { state: 'n/a', error: null };
75
+ },
76
+
77
+ async start() {},
78
+ async stop() {},
79
+
80
+ async send(chatId, msg) {
81
+ const content = renderToMarkdown(msg);
82
+ const url = `${DISCORD_API}/channels/${chatId}/messages`;
83
+ const body = JSON.stringify({ content });
84
+ const headers = {
85
+ 'Content-Type': 'application/json',
86
+ Authorization: authHeader,
87
+ };
88
+
89
+ for (let attempt = 0; attempt <= SEND_BACKOFF_DELAYS.length; attempt++) {
90
+ const res = await fetchFn(url, { method: 'POST', headers, body });
91
+ if (res.status !== 429) {
92
+ if (!res.ok)
93
+ console.warn(`[discord] send failed: HTTP ${res.status}`);
94
+ return;
95
+ }
96
+ if (attempt === SEND_BACKOFF_DELAYS.length) {
97
+ console.warn('[discord] send dropped after retries (429)');
98
+ return;
99
+ }
100
+ const data = await res.json().catch(() => ({}));
101
+ const ms =
102
+ (data.retry_after ?? 0) * 1000 || SEND_BACKOFF_DELAYS[attempt];
103
+ await _sleep(ms);
104
+ }
105
+ },
106
+
107
+ onInbound(_cb) {},
108
+ };
109
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Slack adapter — outbound only, POST to incoming webhook URL (mrkdwn).
3
+ * @module adapters/slack
4
+ */
5
+
6
+ import { toSlackMrkdwn } from '../markdown.js';
7
+
8
+ const SEND_BACKOFF_DELAYS = [1000, 5000, 30000];
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // mrkdwn renderer
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Render a NormalizedMessage to Slack mrkdwn.
16
+ * @param {import('../adapter.js').NormalizedMessage} msg
17
+ * @returns {string}
18
+ */
19
+ export function renderToMrkdwn(msg) {
20
+ const parts = [];
21
+ if (msg.title) {
22
+ parts.push(`*${msg.title}*\n`);
23
+ }
24
+ for (const seg of msg.body) {
25
+ switch (seg.kind) {
26
+ case 'markdown':
27
+ parts.push(toSlackMrkdwn(seg.value));
28
+ break;
29
+ case 'bold':
30
+ parts.push(`*${seg.value}*`);
31
+ break;
32
+ case 'code':
33
+ parts.push(`\`${seg.value}\``);
34
+ break;
35
+ case 'code_block':
36
+ parts.push(`\`\`\`\n${seg.value}\n\`\`\``);
37
+ break;
38
+ case 'link':
39
+ parts.push(`<${seg.href ?? ''}|${seg.value}>`);
40
+ break;
41
+ default: // 'text'
42
+ parts.push(seg.value);
43
+ }
44
+ }
45
+ return parts.join('');
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Adapter factory
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * @param {{
54
+ * webhookUrl?: string,
55
+ * fetchFn?: typeof fetch,
56
+ * _sleep?: (ms: number) => Promise<void>
57
+ * }} options
58
+ * @returns {import('../adapter.js').ChatAdapter}
59
+ */
60
+ export function createSlackAdapter({
61
+ webhookUrl = process.env.SLACK_WEBHOOK_URL,
62
+ fetchFn = globalThis.fetch,
63
+ _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
64
+ } = {}) {
65
+ return {
66
+ name: 'slack',
67
+ supportsInbound: false,
68
+ persistent: false,
69
+
70
+ connectionState() {
71
+ return { state: 'n/a', error: null };
72
+ },
73
+
74
+ async start() {},
75
+ async stop() {},
76
+
77
+ async send(_chatId, msg) {
78
+ const text = renderToMrkdwn(msg);
79
+ const body = JSON.stringify({ text });
80
+ const headers = { 'Content-Type': 'application/json' };
81
+
82
+ for (let attempt = 0; attempt <= SEND_BACKOFF_DELAYS.length; attempt++) {
83
+ const res = await fetchFn(webhookUrl, {
84
+ method: 'POST',
85
+ headers,
86
+ body,
87
+ });
88
+ if (res.status !== 429) {
89
+ if (!res.ok) console.warn(`[slack] send failed: HTTP ${res.status}`);
90
+ return;
91
+ }
92
+ if (attempt === SEND_BACKOFF_DELAYS.length) {
93
+ console.warn('[slack] send dropped after retries (429)');
94
+ return;
95
+ }
96
+ const retryAfter = res.headers?.get('retry-after');
97
+ const ms = retryAfter
98
+ ? Number(retryAfter) * 1000
99
+ : SEND_BACKOFF_DELAYS[attempt];
100
+ await _sleep(ms);
101
+ }
102
+ },
103
+
104
+ onInbound(_cb) {},
105
+ };
106
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Telegram adapter — two-way, long-poll getUpdates + sendMessage (HTML parse_mode).
3
+ * @module adapters/telegram
4
+ */
5
+
6
+ import { mkdir, open, readFile } from 'node:fs/promises';
7
+ import { dirname } from 'node:path';
8
+ import { toTelegramHtml } from '../markdown.js';
9
+
10
+ const TELEGRAM_API = 'https://api.telegram.org';
11
+ const LONG_POLL_TIMEOUT_SEC = 30;
12
+ const SEND_BACKOFF_DELAYS = [1000, 5000, 30000];
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // HTML escaping + renderer
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function escapeHtml(text) {
19
+ return String(text)
20
+ .replace(/&/g, '&amp;')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;');
24
+ }
25
+
26
+ /**
27
+ * Render a NormalizedMessage to Telegram HTML.
28
+ * @param {import('../adapter.js').NormalizedMessage} msg
29
+ * @returns {string}
30
+ */
31
+ export function renderToHtml(msg) {
32
+ const parts = [];
33
+ if (msg.title) {
34
+ parts.push(`<b>${escapeHtml(msg.title)}</b>\n`);
35
+ }
36
+ for (const seg of msg.body) {
37
+ switch (seg.kind) {
38
+ case 'markdown':
39
+ parts.push(toTelegramHtml(seg.value));
40
+ break;
41
+ case 'bold':
42
+ parts.push(`<b>${escapeHtml(seg.value)}</b>`);
43
+ break;
44
+ case 'code':
45
+ parts.push(`<code>${escapeHtml(seg.value)}</code>`);
46
+ break;
47
+ case 'code_block':
48
+ parts.push(`<pre>${escapeHtml(seg.value)}</pre>`);
49
+ break;
50
+ case 'link':
51
+ parts.push(
52
+ `<a href="${escapeHtml(seg.href ?? '')}">${escapeHtml(seg.value)}</a>`,
53
+ );
54
+ break;
55
+ default: // 'text'
56
+ parts.push(escapeHtml(seg.value));
57
+ }
58
+ }
59
+ return parts.join('');
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Cursor persistence (fsynced)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ async function readCursor(cursorPath) {
67
+ try {
68
+ const data = await readFile(cursorPath, 'utf8');
69
+ const n = parseInt(data.trim(), 10);
70
+ return Number.isFinite(n) ? n : 0;
71
+ } catch {
72
+ return 0;
73
+ }
74
+ }
75
+
76
+ async function writeCursor(cursorPath, offset) {
77
+ await mkdir(dirname(cursorPath), { recursive: true });
78
+ const fh = await open(cursorPath, 'w');
79
+ try {
80
+ await fh.write(String(offset));
81
+ await fh.datasync();
82
+ } finally {
83
+ await fh.close();
84
+ }
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Adapter factory
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * @param {{
93
+ * token: string,
94
+ * cursorPath: string,
95
+ * fetchFn?: typeof fetch,
96
+ * _sleep?: (ms: number) => Promise<void>
97
+ * }} options
98
+ * @returns {import('../adapter.js').ChatAdapter}
99
+ */
100
+ export function createTelegramAdapter({
101
+ token,
102
+ cursorPath,
103
+ fetchFn = globalThis.fetch,
104
+ _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
105
+ }) {
106
+ let inboundCb = null;
107
+ let running = false;
108
+ // Connection health tracking
109
+ let connState = 'connecting'; // 'connecting' | 'connected' | 'disconnected'
110
+ let connError = null;
111
+ let lastPollOk = null; // ISO timestamp of last successful poll
112
+
113
+ async function pollLoop() {
114
+ let cursor = await readCursor(cursorPath);
115
+ let firstPoll = true;
116
+ while (running) {
117
+ try {
118
+ const pollTimeout = firstPoll ? 0 : LONG_POLL_TIMEOUT_SEC;
119
+ const url =
120
+ `${TELEGRAM_API}/bot${token}/getUpdates` +
121
+ `?offset=${cursor}&timeout=${pollTimeout}`;
122
+ const res = await fetchFn(url);
123
+ firstPoll = false;
124
+ if (res.status === 429) {
125
+ const data = await res.json().catch(() => ({}));
126
+ const ms = (data.parameters?.retry_after ?? 1) * 1000;
127
+ await _sleep(ms);
128
+ continue;
129
+ }
130
+ if (!res.ok) {
131
+ connState = 'disconnected';
132
+ connError = `HTTP ${res.status}`;
133
+ if (running) await _sleep(5000);
134
+ continue;
135
+ }
136
+ connState = 'connected';
137
+ connError = null;
138
+ lastPollOk = new Date().toISOString();
139
+ const data = await res.json();
140
+ if (data.ok && data.result.length > 0) {
141
+ for (const update of data.result) {
142
+ cursor = update.update_id + 1;
143
+ if (inboundCb && update.message) {
144
+ const m = update.message;
145
+ inboundCb({
146
+ platform: 'telegram',
147
+ chatId: String(m.chat.id),
148
+ userId: String(m.from?.id ?? m.chat.id),
149
+ text: m.text ?? '',
150
+ raw: update,
151
+ });
152
+ }
153
+ }
154
+ await writeCursor(cursorPath, cursor);
155
+ }
156
+ } catch (err) {
157
+ connState = 'disconnected';
158
+ connError = err.message;
159
+ console.error('[telegram] poll error:', err.message);
160
+ if (running) await _sleep(1000);
161
+ }
162
+ }
163
+ }
164
+
165
+ return {
166
+ name: 'telegram',
167
+ supportsInbound: true,
168
+ persistent: true,
169
+
170
+ connectionState() {
171
+ // If the last successful poll is older than 2× the poll timeout,
172
+ // the connection is stale (e.g. network dropped, fetch is hanging).
173
+ let effectiveState = connState;
174
+ let effectiveError = connError;
175
+ if (connState === 'connected' && lastPollOk) {
176
+ const staleMs = (LONG_POLL_TIMEOUT_SEC * 2 + 10) * 1000; // ~70s
177
+ if (Date.now() - new Date(lastPollOk).getTime() > staleMs) {
178
+ effectiveState = 'disconnected';
179
+ effectiveError = 'Connection stale — no poll response';
180
+ }
181
+ }
182
+ return { state: effectiveState, error: effectiveError, lastPollOk };
183
+ },
184
+
185
+ async start() {
186
+ running = true;
187
+ connState = 'connecting';
188
+ connError = null;
189
+ pollLoop().catch((err) =>
190
+ console.error('[telegram] fatal poll error:', err.message),
191
+ );
192
+ },
193
+
194
+ async stop() {
195
+ running = false;
196
+ },
197
+
198
+ async send(chatId, msg) {
199
+ const text = renderToHtml(msg);
200
+ const url = `${TELEGRAM_API}/bot${token}/sendMessage`;
201
+ const body = JSON.stringify({
202
+ chat_id: chatId,
203
+ text,
204
+ parse_mode: 'HTML',
205
+ });
206
+ const headers = { 'Content-Type': 'application/json' };
207
+
208
+ for (let attempt = 0; attempt <= SEND_BACKOFF_DELAYS.length; attempt++) {
209
+ const res = await fetchFn(url, { method: 'POST', headers, body });
210
+ if (res.status !== 429) {
211
+ if (!res.ok)
212
+ console.warn(`[telegram] sendMessage failed: HTTP ${res.status}`);
213
+ return;
214
+ }
215
+ if (attempt === SEND_BACKOFF_DELAYS.length) {
216
+ console.warn('[telegram] sendMessage dropped after retries (429)');
217
+ return;
218
+ }
219
+ const data = await res.json().catch(() => ({}));
220
+ const ms =
221
+ (data.parameters?.retry_after ?? 0) * 1000 ||
222
+ SEND_BACKOFF_DELAYS[attempt];
223
+ await _sleep(ms);
224
+ }
225
+ },
226
+
227
+ onInbound(cb) {
228
+ inboundCb = cb;
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Generic outbound webhook adapter — POSTs to configured URL(s) with templated payloads.
3
+ * @module adapters/webhook_out
4
+ */
5
+
6
+ import { toDiscordMarkdown, toPlainText, toSlackMrkdwn } from '../markdown.js';
7
+
8
+ const SEND_BACKOFF_DELAYS = [1000, 5000, 30000];
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Internal text helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function bodyToPlain(msg) {
15
+ return msg.body
16
+ .map((seg) =>
17
+ seg.kind === 'markdown' ? toPlainText(seg.value) : seg.value,
18
+ )
19
+ .join('');
20
+ }
21
+
22
+ function bodyToMrkdwn(msg) {
23
+ const parts = [];
24
+ if (msg.title) parts.push(`*${msg.title}*\n`);
25
+ for (const seg of msg.body) {
26
+ switch (seg.kind) {
27
+ case 'markdown':
28
+ parts.push(toSlackMrkdwn(seg.value));
29
+ break;
30
+ case 'bold':
31
+ parts.push(`*${seg.value}*`);
32
+ break;
33
+ case 'code':
34
+ parts.push(`\`${seg.value}\``);
35
+ break;
36
+ case 'code_block':
37
+ parts.push(`\`\`\`\n${seg.value}\n\`\`\``);
38
+ break;
39
+ case 'link':
40
+ parts.push(`<${seg.href ?? ''}|${seg.value}>`);
41
+ break;
42
+ default:
43
+ parts.push(seg.value);
44
+ }
45
+ }
46
+ return parts.join('');
47
+ }
48
+
49
+ function bodyToMarkdown(msg) {
50
+ const parts = [];
51
+ if (msg.title) parts.push(`**${msg.title}**\n`);
52
+ for (const seg of msg.body) {
53
+ switch (seg.kind) {
54
+ case 'markdown':
55
+ parts.push(toDiscordMarkdown(seg.value));
56
+ break;
57
+ case 'bold':
58
+ parts.push(`**${seg.value}**`);
59
+ break;
60
+ case 'code':
61
+ parts.push(`\`${seg.value}\``);
62
+ break;
63
+ case 'code_block':
64
+ parts.push(`\`\`\`\n${seg.value}\n\`\`\``);
65
+ break;
66
+ case 'link':
67
+ parts.push(`[${seg.value}](${seg.href ?? ''})`);
68
+ break;
69
+ default:
70
+ parts.push(seg.value);
71
+ }
72
+ }
73
+ return parts.join('');
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Payload templates
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * @param {import('../adapter.js').NormalizedMessage} msg
82
+ * @returns {object}
83
+ */
84
+ export function renderAsGenericJson(msg) {
85
+ return {
86
+ title: msg.title,
87
+ severity: msg.severity,
88
+ text: bodyToPlain(msg),
89
+ segments: msg.body,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * @param {import('../adapter.js').NormalizedMessage} msg
95
+ * @returns {{ text: string }}
96
+ */
97
+ export function renderAsSlackCompatible(msg) {
98
+ return { text: bodyToMrkdwn(msg) };
99
+ }
100
+
101
+ /**
102
+ * @param {import('../adapter.js').NormalizedMessage} msg
103
+ * @returns {{ content: string }}
104
+ */
105
+ export function renderAsDiscordCompatible(msg) {
106
+ return { content: bodyToMarkdown(msg) };
107
+ }
108
+
109
+ /**
110
+ * @param {import('../adapter.js').NormalizedMessage} msg
111
+ * @returns {object}
112
+ */
113
+ export function renderAsTeamsCard(msg) {
114
+ const cardBody = [];
115
+ if (msg.title) {
116
+ cardBody.push({
117
+ type: 'TextBlock',
118
+ text: msg.title,
119
+ weight: 'bolder',
120
+ size: 'medium',
121
+ });
122
+ }
123
+ cardBody.push({ type: 'TextBlock', text: bodyToPlain(msg), wrap: true });
124
+ return {
125
+ type: 'message',
126
+ attachments: [
127
+ {
128
+ contentType: 'application/vnd.microsoft.card.adaptive',
129
+ content: {
130
+ type: 'AdaptiveCard',
131
+ $schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
132
+ version: '1.2',
133
+ body: cardBody,
134
+ },
135
+ },
136
+ ],
137
+ };
138
+ }
139
+
140
+ const NTFY_PRIORITY = { info: 2, success: 3, warning: 4, error: 5 };
141
+
142
+ /**
143
+ * @param {import('../adapter.js').NormalizedMessage} msg
144
+ * @returns {object}
145
+ */
146
+ export function renderAsNtfy(msg) {
147
+ return {
148
+ title: msg.title ?? undefined,
149
+ message: bodyToPlain(msg),
150
+ priority: NTFY_PRIORITY[msg.severity] ?? 3,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * @param {import('../adapter.js').NormalizedMessage} msg
156
+ * @returns {string}
157
+ */
158
+ export function renderAsPlainText(msg) {
159
+ const parts = [];
160
+ if (msg.title) parts.push(msg.title);
161
+ const body = bodyToPlain(msg);
162
+ if (body) parts.push(body);
163
+ return parts.join('\n');
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Template registry
168
+ // ---------------------------------------------------------------------------
169
+
170
+ const TEMPLATES = {
171
+ 'generic-json': renderAsGenericJson,
172
+ 'slack-compatible': renderAsSlackCompatible,
173
+ 'discord-compatible': renderAsDiscordCompatible,
174
+ 'teams-card': renderAsTeamsCard,
175
+ ntfy: renderAsNtfy,
176
+ 'plain-text': renderAsPlainText,
177
+ };
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Internal: send to one endpoint
181
+ // ---------------------------------------------------------------------------
182
+
183
+ async function sendToEndpoint(ep, msg, fetchFn, _sleep) {
184
+ const render = TEMPLATES[ep.format] ?? renderAsGenericJson;
185
+ const payload = render(msg);
186
+ const isPlainText = ep.format === 'plain-text';
187
+ const body = isPlainText ? payload : JSON.stringify(payload);
188
+ const headers = {
189
+ 'Content-Type': isPlainText ? 'text/plain' : 'application/json',
190
+ ...ep.headers,
191
+ };
192
+
193
+ for (let attempt = 0; attempt <= SEND_BACKOFF_DELAYS.length; attempt++) {
194
+ const res = await fetchFn(ep.url, { method: 'POST', headers, body });
195
+ if (res.status !== 429) {
196
+ if (!res.ok)
197
+ console.warn(
198
+ `[webhook_out] send to ${ep.name ?? ep.url} failed: HTTP ${res.status}`,
199
+ );
200
+ return;
201
+ }
202
+ if (attempt === SEND_BACKOFF_DELAYS.length) {
203
+ console.warn(
204
+ `[webhook_out] send to ${ep.name ?? ep.url} dropped after retries (429)`,
205
+ );
206
+ return;
207
+ }
208
+ const retryAfter = res.headers?.get?.('retry-after');
209
+ const ms = retryAfter
210
+ ? Number(retryAfter) * 1000
211
+ : SEND_BACKOFF_DELAYS[attempt];
212
+ await _sleep(ms);
213
+ }
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Adapter factory
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * @param {{
222
+ * endpoints?: Array<{url: string, format: string, headers?: object, name?: string}>,
223
+ * fetchFn?: typeof fetch,
224
+ * _sleep?: (ms: number) => Promise<void>
225
+ * }} options
226
+ * @returns {import('../adapter.js').ChatAdapter}
227
+ */
228
+ export function createWebhookOutAdapter({
229
+ endpoints = [],
230
+ fetchFn = globalThis.fetch,
231
+ _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
232
+ } = {}) {
233
+ return {
234
+ name: 'webhook_out',
235
+ supportsInbound: false,
236
+ persistent: false,
237
+
238
+ connectionState() {
239
+ return { state: 'n/a', error: null };
240
+ },
241
+
242
+ async start() {},
243
+ async stop() {},
244
+
245
+ async send(_chatId, msg) {
246
+ await Promise.all(
247
+ endpoints.map((ep) => sendToEndpoint(ep, msg, fetchFn, _sleep)),
248
+ );
249
+ },
250
+
251
+ onInbound(_cb) {},
252
+ };
253
+ }