@worca/ui 0.9.0-rc.1 → 0.9.0-rc.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.9.0-rc.1",
3
+ "version": "0.9.0-rc.2",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -23,9 +23,10 @@
23
23
  },
24
24
  "files": [
25
25
  "bin/worca-ui.js",
26
- "server/*.js",
27
- "!server/*.test.js",
28
- "!server/test/",
26
+ "server/**/*.js",
27
+ "!server/**/*.test.js",
28
+ "!server/test/**",
29
+ "!server/**/test/**",
29
30
  "app/favicon-*.svg",
30
31
  "app/index.html",
31
32
  "app/main.bundle.js",
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @typedef {('text'|'bold'|'code'|'code_block'|'link')} MessageSegmentKind
3
+ *
4
+ * @typedef {{
5
+ * kind: MessageSegmentKind,
6
+ * value: string,
7
+ * href?: string
8
+ * }} MessageSegment
9
+ *
10
+ * @typedef {{
11
+ * title: string|null,
12
+ * body: MessageSegment[],
13
+ * severity: 'info'|'success'|'warning'|'error'
14
+ * }} NormalizedMessage
15
+ *
16
+ * @typedef {{
17
+ * platform: string,
18
+ * chatId: string,
19
+ * userId: string,
20
+ * text: string,
21
+ * raw: object
22
+ * }} IncomingMessage
23
+ *
24
+ * @typedef {object} ChatAdapter
25
+ * @property {string} name
26
+ * @property {boolean} supportsInbound
27
+ * @property {() => Promise<void>} start
28
+ * @property {() => Promise<void>} stop
29
+ * @property {(chatId: string, msg: NormalizedMessage) => Promise<void>} send
30
+ * @property {(cb: (msg: IncomingMessage) => void) => void} onInbound
31
+ */
32
+
33
+ export const MESSAGE_SEGMENT_KINDS = [
34
+ 'text',
35
+ 'bold',
36
+ 'code',
37
+ 'code_block',
38
+ 'link',
39
+ 'markdown',
40
+ ];
41
+
42
+ export const SEVERITY_LEVELS = ['info', 'success', 'warning', 'error'];
43
+
44
+ export const ADAPTER_INTERFACE_KEYS = [
45
+ 'name',
46
+ 'supportsInbound',
47
+ 'start',
48
+ 'stop',
49
+ 'send',
50
+ 'onInbound',
51
+ ];
52
+
53
+ /** @param {unknown} seg @returns {seg is MessageSegment} */
54
+ export function isValidSegment(seg) {
55
+ if (!seg || typeof seg !== 'object') return false;
56
+ return (
57
+ MESSAGE_SEGMENT_KINDS.includes(seg.kind) && typeof seg.value === 'string'
58
+ );
59
+ }
60
+
61
+ /** @param {unknown} msg @returns {msg is NormalizedMessage} */
62
+ export function isValidMessage(msg) {
63
+ if (!msg || typeof msg !== 'object') return false;
64
+ if (msg.title !== null && typeof msg.title !== 'string') return false;
65
+ if (!Array.isArray(msg.body) || !msg.body.every(isValidSegment)) return false;
66
+ return SEVERITY_LEVELS.includes(msg.severity);
67
+ }
68
+
69
+ /** @param {unknown} inc @returns {inc is IncomingMessage} */
70
+ export function isValidIncoming(inc) {
71
+ if (!inc || typeof inc !== 'object') return false;
72
+ return (
73
+ typeof inc.platform === 'string' &&
74
+ typeof inc.chatId === 'string' &&
75
+ typeof inc.userId === 'string' &&
76
+ typeof inc.text === 'string' &&
77
+ inc.raw !== undefined
78
+ );
79
+ }
80
+
81
+ /** @param {unknown} adapter @returns {adapter is ChatAdapter} */
82
+ export function isValidAdapter(adapter) {
83
+ if (!adapter || typeof adapter !== 'object') return false;
84
+ return (
85
+ typeof adapter.name === 'string' &&
86
+ typeof adapter.supportsInbound === 'boolean' &&
87
+ typeof adapter.start === 'function' &&
88
+ typeof adapter.send === 'function' &&
89
+ typeof adapter.onInbound === 'function'
90
+ );
91
+ }
@@ -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,228 @@
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
+ while (running) {
116
+ try {
117
+ const url =
118
+ `${TELEGRAM_API}/bot${token}/getUpdates` +
119
+ `?offset=${cursor}&timeout=${LONG_POLL_TIMEOUT_SEC}`;
120
+ const res = await fetchFn(url);
121
+ if (res.status === 429) {
122
+ const data = await res.json().catch(() => ({}));
123
+ const ms = (data.parameters?.retry_after ?? 1) * 1000;
124
+ await _sleep(ms);
125
+ continue;
126
+ }
127
+ if (!res.ok) {
128
+ connState = 'disconnected';
129
+ connError = `HTTP ${res.status}`;
130
+ if (running) await _sleep(5000);
131
+ continue;
132
+ }
133
+ connState = 'connected';
134
+ connError = null;
135
+ lastPollOk = new Date().toISOString();
136
+ const data = await res.json();
137
+ if (data.ok && data.result.length > 0) {
138
+ for (const update of data.result) {
139
+ cursor = update.update_id + 1;
140
+ if (inboundCb && update.message) {
141
+ const m = update.message;
142
+ inboundCb({
143
+ platform: 'telegram',
144
+ chatId: String(m.chat.id),
145
+ userId: String(m.from?.id ?? m.chat.id),
146
+ text: m.text ?? '',
147
+ raw: update,
148
+ });
149
+ }
150
+ }
151
+ await writeCursor(cursorPath, cursor);
152
+ }
153
+ } catch (err) {
154
+ connState = 'disconnected';
155
+ connError = err.message;
156
+ console.error('[telegram] poll error:', err.message);
157
+ if (running) await _sleep(1000);
158
+ }
159
+ }
160
+ }
161
+
162
+ return {
163
+ name: 'telegram',
164
+ supportsInbound: true,
165
+ persistent: true,
166
+
167
+ connectionState() {
168
+ // If the last successful poll is older than 2× the poll timeout,
169
+ // the connection is stale (e.g. network dropped, fetch is hanging).
170
+ let effectiveState = connState;
171
+ let effectiveError = connError;
172
+ if (connState === 'connected' && lastPollOk) {
173
+ const staleMs = (LONG_POLL_TIMEOUT_SEC * 2 + 10) * 1000; // ~70s
174
+ if (Date.now() - new Date(lastPollOk).getTime() > staleMs) {
175
+ effectiveState = 'disconnected';
176
+ effectiveError = 'Connection stale — no poll response';
177
+ }
178
+ }
179
+ return { state: effectiveState, error: effectiveError, lastPollOk };
180
+ },
181
+
182
+ async start() {
183
+ running = true;
184
+ connState = 'connecting';
185
+ connError = null;
186
+ pollLoop().catch((err) =>
187
+ console.error('[telegram] fatal poll error:', err.message),
188
+ );
189
+ },
190
+
191
+ async stop() {
192
+ running = false;
193
+ },
194
+
195
+ async send(chatId, msg) {
196
+ const text = renderToHtml(msg);
197
+ const url = `${TELEGRAM_API}/bot${token}/sendMessage`;
198
+ const body = JSON.stringify({
199
+ chat_id: chatId,
200
+ text,
201
+ parse_mode: 'HTML',
202
+ });
203
+ const headers = { 'Content-Type': 'application/json' };
204
+
205
+ for (let attempt = 0; attempt <= SEND_BACKOFF_DELAYS.length; attempt++) {
206
+ const res = await fetchFn(url, { method: 'POST', headers, body });
207
+ if (res.status !== 429) {
208
+ if (!res.ok)
209
+ console.warn(`[telegram] sendMessage failed: HTTP ${res.status}`);
210
+ return;
211
+ }
212
+ if (attempt === SEND_BACKOFF_DELAYS.length) {
213
+ console.warn('[telegram] sendMessage dropped after retries (429)');
214
+ return;
215
+ }
216
+ const data = await res.json().catch(() => ({}));
217
+ const ms =
218
+ (data.parameters?.retry_after ?? 0) * 1000 ||
219
+ SEND_BACKOFF_DELAYS[attempt];
220
+ await _sleep(ms);
221
+ }
222
+ },
223
+
224
+ onInbound(cb) {
225
+ inboundCb = cb;
226
+ },
227
+ };
228
+ }