@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 +5 -4
- 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 +228 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/ui",
|
|
3
|
-
"version": "0.9.0-rc.
|
|
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
|
|
27
|
-
"!server
|
|
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, '&')
|
|
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
|
+
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
|
+
}
|