@worca/ui 0.9.0 → 0.11.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.
- package/app/main.bundle.js +895 -813
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +216 -9
- package/app/utils/state-actions.js +55 -0
- package/package.json +6 -4
- package/server/app.js +291 -6
- package/server/beads-reader.js +1 -1
- package/server/dispatch-external.js +106 -0
- package/server/ensure-webhook.js +66 -0
- package/server/index.js +22 -0
- package/server/integrations/adapter.js +91 -0
- package/server/integrations/adapters/discord.js +109 -0
- package/server/integrations/adapters/slack.js +106 -0
- package/server/integrations/adapters/telegram.js +231 -0
- package/server/integrations/adapters/webhook_out.js +253 -0
- package/server/integrations/allowlist.js +19 -0
- package/server/integrations/chat_context.js +68 -0
- package/server/integrations/commands/control.js +120 -0
- package/server/integrations/commands/global.js +239 -0
- package/server/integrations/commands/parser.js +29 -0
- package/server/integrations/commands/project.js +394 -0
- package/server/integrations/config-loader.js +40 -0
- package/server/integrations/index.js +390 -0
- package/server/integrations/markdown.js +220 -0
- package/server/integrations/rate_limiter.js +131 -0
- package/server/integrations/renderers.js +191 -0
- package/server/integrations/rest_client.js +17 -0
- package/server/integrations/verify.js +23 -0
- package/server/process-manager.js +217 -14
- package/server/project-routes.js +210 -44
- package/server/settings-validator.js +250 -0
- package/server/ws-beads-watcher.js +22 -6
- 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, '&')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"');
|
|
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
|
+
}
|