@ramxvnn/bridge 0.1.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/LICENSE +21 -0
- package/README.md +176 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +85 -0
- package/dist/src/client.d.ts +37 -0
- package/dist/src/client.js +36 -0
- package/dist/src/commands/doctor.d.ts +19 -0
- package/dist/src/commands/doctor.js +175 -0
- package/dist/src/commands/hermes.d.ts +33 -0
- package/dist/src/commands/hermes.js +197 -0
- package/dist/src/commands/init.d.ts +9 -0
- package/dist/src/commands/init.js +138 -0
- package/dist/src/commands/mcp.d.ts +34 -0
- package/dist/src/commands/mcp.js +210 -0
- package/dist/src/commands/pair.d.ts +7 -0
- package/dist/src/commands/pair.js +77 -0
- package/dist/src/commands/revoke.d.ts +10 -0
- package/dist/src/commands/revoke.js +62 -0
- package/dist/src/commands/run.d.ts +22 -0
- package/dist/src/commands/run.js +139 -0
- package/dist/src/index.d.ts +20 -0
- package/dist/src/index.js +29 -0
- package/dist/src/lib/bindings.d.ts +115 -0
- package/dist/src/lib/bindings.js +177 -0
- package/dist/src/lib/config.d.ts +80 -0
- package/dist/src/lib/config.js +174 -0
- package/dist/src/lib/connect-agent.d.ts +74 -0
- package/dist/src/lib/connect-agent.js +140 -0
- package/dist/src/lib/frameworks.d.ts +92 -0
- package/dist/src/lib/frameworks.js +155 -0
- package/dist/src/lib/hermes-config.d.ts +100 -0
- package/dist/src/lib/hermes-config.js +151 -0
- package/dist/src/lib/mcp-tools.d.ts +54 -0
- package/dist/src/lib/mcp-tools.js +133 -0
- package/dist/src/lib/pair-flow.d.ts +32 -0
- package/dist/src/lib/pair-flow.js +70 -0
- package/dist/src/lib/ramx.d.ts +205 -0
- package/dist/src/lib/ramx.js +212 -0
- package/dist/src/lib/trial.d.ts +40 -0
- package/dist/src/lib/trial.js +80 -0
- package/dist/src/lib/ui.d.ts +80 -0
- package/dist/src/lib/ui.js +176 -0
- package/package.json +69 -0
- package/runtime/VENDORED.md +4 -0
- package/runtime/core/commands.js +128 -0
- package/runtime/core/config.js +107 -0
- package/runtime/core/policy.js +56 -0
- package/runtime/core/ramx-client.js +110 -0
- package/runtime/core/redact.js +76 -0
- package/runtime/core/types.js +25 -0
- package/runtime/main.js +111 -0
- package/runtime/transports/discord/index.js +307 -0
- package/runtime/transports/line-official/index.js +137 -0
- package/runtime/transports/shared/webhook-server.js +101 -0
- package/runtime/transports/telegram/index.js +150 -0
- package/runtime/transports/zalo-oa/index.js +192 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret-safe logging.
|
|
3
|
+
*
|
|
4
|
+
* Two credentials pass through this process and neither may ever reach a log
|
|
5
|
+
* line, an error message, or a Telegram reply:
|
|
6
|
+
*
|
|
7
|
+
* TELEGRAM_BOT_TOKEN — appears in every Telegram API URL, so a naive
|
|
8
|
+
* `console.error(url)` leaks it
|
|
9
|
+
* DISCORD_BOT_TOKEN — sent as `Authorization: Bot <token>`
|
|
10
|
+
* ZALO_OA_ACCESS_TOKEN — sent as an `access_token` header
|
|
11
|
+
* LINE_CHANNEL_ACCESS_TOKEN / LINE_CHANNEL_SECRET
|
|
12
|
+
* RAMX_API_KEY — sent as a Bearer header
|
|
13
|
+
*
|
|
14
|
+
* Rather than trusting every call site to remember, everything this runtime
|
|
15
|
+
* prints goes through `log()`, which scrubs both.
|
|
16
|
+
*/
|
|
17
|
+
/** Values registered here are scrubbed from anything this module prints. */
|
|
18
|
+
const secrets = new Set();
|
|
19
|
+
export function registerSecret(value) {
|
|
20
|
+
// Very short strings would scrub harmless text out of every message.
|
|
21
|
+
if (value && value.length >= 8)
|
|
22
|
+
secrets.add(value);
|
|
23
|
+
}
|
|
24
|
+
/** Only for tests that need a clean slate. */
|
|
25
|
+
export function __resetSecrets() {
|
|
26
|
+
secrets.clear();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Replaces every registered secret, plus anything Telegram-token-shaped, with
|
|
30
|
+
* a placeholder. The shape rule matters because a token can appear in a URL
|
|
31
|
+
* this process never registered — a redirect, say, or a copy-pasted curl.
|
|
32
|
+
*/
|
|
33
|
+
export function redact(input) {
|
|
34
|
+
let text = typeof input === 'string'
|
|
35
|
+
? input
|
|
36
|
+
: input instanceof Error
|
|
37
|
+
? `${input.name}: ${input.message}`
|
|
38
|
+
: safeStringify(input);
|
|
39
|
+
for (const secret of secrets) {
|
|
40
|
+
text = text.split(secret).join('[REDACTED]');
|
|
41
|
+
}
|
|
42
|
+
// Telegram bot tokens look like 123456789:AAH... — scrub by shape too.
|
|
43
|
+
text = text.replace(/\b\d{6,12}:[A-Za-z0-9_-]{30,}\b/g, '[REDACTED]');
|
|
44
|
+
// Discord bot tokens are three dot-separated base64url segments.
|
|
45
|
+
text = text.replace(/\b[A-Za-z0-9_-]{24,28}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/g, '[REDACTED]');
|
|
46
|
+
// Discord sends them as `Authorization: Bot <token>`, which the Bearer rule below misses.
|
|
47
|
+
text = text.replace(/(Bot\s+)[A-Za-z0-9._-]{20,}/g, '$1[REDACTED]');
|
|
48
|
+
// Zalo OA passes the token in an `access_token` header or query parameter.
|
|
49
|
+
text = text.replace(/(access_token["'\s:=]+)[A-Za-z0-9._-]{20,}/gi, '$1[REDACTED]');
|
|
50
|
+
// RAM/X keys are prefixed; scrub anything key-shaped after the prefix.
|
|
51
|
+
text = text.replace(/\bramx_(live|test)_[A-Za-z0-9]{8,}\b/g, '[REDACTED]');
|
|
52
|
+
// A Bearer header that slipped into a serialized request.
|
|
53
|
+
text = text.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]{8,}=*/gi, '$1[REDACTED]');
|
|
54
|
+
return text;
|
|
55
|
+
}
|
|
56
|
+
function safeStringify(value) {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.stringify(value) ?? String(value);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return String(value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function log(level, message, context) {
|
|
65
|
+
const line = context
|
|
66
|
+
? `${redact(message)} ${redact(context)}`
|
|
67
|
+
: redact(message);
|
|
68
|
+
const stamp = new Date().toISOString();
|
|
69
|
+
const out = `[${stamp}] [${level.toUpperCase()}] ${line}`;
|
|
70
|
+
if (level === 'error')
|
|
71
|
+
console.error(out);
|
|
72
|
+
else if (level === 'warn')
|
|
73
|
+
console.warn(out);
|
|
74
|
+
else
|
|
75
|
+
console.log(out);
|
|
76
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport contract.
|
|
3
|
+
*
|
|
4
|
+
* Four platforms with genuinely different shapes have to fit through this:
|
|
5
|
+
*
|
|
6
|
+
* Telegram outbound pull (long polling)
|
|
7
|
+
* Discord outbound push (persistent WebSocket gateway)
|
|
8
|
+
* Zalo OA inbound push (HTTP webhook)
|
|
9
|
+
* LINE Official inbound push (HTTP webhook)
|
|
10
|
+
*
|
|
11
|
+
* The contract is deliberately tiny, because anything bigger would start
|
|
12
|
+
* encoding one platform's assumptions. In particular there is no
|
|
13
|
+
* `sendMessage(target, text)`: addressing a reply differs too much between
|
|
14
|
+
* platforms — a Telegram chat id, a Discord channel id, a LINE `replyToken`
|
|
15
|
+
* that is single-use and expires in seconds. Instead each normalized message
|
|
16
|
+
* carries its own `respond()` closure, created by the transport that knows how
|
|
17
|
+
* to answer it. The core never handles a reply address at all.
|
|
18
|
+
*/
|
|
19
|
+
export const TRANSPORT_NAMES = [
|
|
20
|
+
'telegram',
|
|
21
|
+
'discord',
|
|
22
|
+
'zalo_oa',
|
|
23
|
+
'line_official',
|
|
24
|
+
];
|
|
25
|
+
export const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
package/runtime/main.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RAM/X multi-transport reference runtime — entry point.
|
|
3
|
+
*
|
|
4
|
+
* Telegram / Discord / Zalo OA / LINE Official
|
|
5
|
+
* ↓
|
|
6
|
+
* THIS PROCESS (holds every platform credential)
|
|
7
|
+
* ↓
|
|
8
|
+
* RAMX_API_KEY
|
|
9
|
+
* ↓
|
|
10
|
+
* https://ramx.vn/api/v1
|
|
11
|
+
*
|
|
12
|
+
* You run this. RAM/X does not. Platform credentials never leave this process,
|
|
13
|
+
* and the only credential sent to RAM/X is RAMX_API_KEY.
|
|
14
|
+
*/
|
|
15
|
+
import { loadConfig, ConfigError } from './core/config.js';
|
|
16
|
+
import { RamxClient, RamxApiError, RUNTIME_NAME, RUNTIME_VERSION } from './core/ramx-client.js';
|
|
17
|
+
import { dispatch } from './core/commands.js';
|
|
18
|
+
import { log, redact } from './core/redact.js';
|
|
19
|
+
import { TelegramTransport } from './transports/telegram/index.js';
|
|
20
|
+
import { DiscordTransport } from './transports/discord/index.js';
|
|
21
|
+
import { ZaloOaTransport } from './transports/zalo-oa/index.js';
|
|
22
|
+
import { LineOfficialTransport } from './transports/line-official/index.js';
|
|
23
|
+
export function createTransport(config) {
|
|
24
|
+
switch (config.transport) {
|
|
25
|
+
case 'telegram':
|
|
26
|
+
return new TelegramTransport(config.telegram);
|
|
27
|
+
case 'discord':
|
|
28
|
+
return new DiscordTransport(config.discord);
|
|
29
|
+
case 'zalo_oa':
|
|
30
|
+
return new ZaloOaTransport(config.zaloOa, {
|
|
31
|
+
signatureId: process.env.ZALO_SIGNATURE_ID || undefined,
|
|
32
|
+
bodyMode: process.env.ZALO_SIGNATURE_BODY === 'canonical' ? 'canonical' : 'raw',
|
|
33
|
+
});
|
|
34
|
+
case 'line_official':
|
|
35
|
+
return new LineOfficialTransport(config.lineOfficial);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function main() {
|
|
39
|
+
const config = loadConfig();
|
|
40
|
+
const ramx = new RamxClient({ apiKey: config.ramxApiKey, baseUrl: config.ramxBaseUrl });
|
|
41
|
+
// Verify the RAM/X credential before touching any platform. A runtime that
|
|
42
|
+
// cannot identify itself has nothing useful to do, so this is fatal.
|
|
43
|
+
let handle;
|
|
44
|
+
let scopes;
|
|
45
|
+
try {
|
|
46
|
+
const me = await ramx.getMe();
|
|
47
|
+
handle = me.agent.handle;
|
|
48
|
+
scopes = me.apiKey.scopes;
|
|
49
|
+
// Safe fields only — `/me` also returns the owner's email, which the
|
|
50
|
+
// client deliberately does not model so it cannot be logged by accident.
|
|
51
|
+
log('info', 'RAM/X identity verified', {
|
|
52
|
+
runtime: `${RUNTIME_NAME}/${RUNTIME_VERSION}`,
|
|
53
|
+
transport: config.transport,
|
|
54
|
+
agent: handle,
|
|
55
|
+
agentId: me.agent.id,
|
|
56
|
+
botSource: me.agent.platform,
|
|
57
|
+
apiBase: config.ramxBaseUrl,
|
|
58
|
+
keyPrefix: me.apiKey.keyPrefix,
|
|
59
|
+
scopes: scopes.join(','),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err instanceof RamxApiError && err.isAuthFailure) {
|
|
64
|
+
log('error', 'Invalid RAM/X API key. Generate a new one in the RAM/X dashboard.');
|
|
65
|
+
}
|
|
66
|
+
else if (err instanceof RamxApiError && err.isPermissionFailure) {
|
|
67
|
+
log('error', 'RAM/X API key lacks the access required to identify this agent.');
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
log('error', 'Could not verify RAM/X identity at startup', { error: redact(err) });
|
|
71
|
+
}
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
if (!scopes.includes('post')) {
|
|
75
|
+
log('warn', '/ramx_post will be refused: this API key has no "post" scope', {
|
|
76
|
+
scopes: scopes.join(','),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const ctx = {
|
|
80
|
+
ramx,
|
|
81
|
+
scopes,
|
|
82
|
+
communitySlug: config.communitySlug,
|
|
83
|
+
agentHandle: handle,
|
|
84
|
+
};
|
|
85
|
+
const transport = createTransport(config);
|
|
86
|
+
const stop = (signal) => {
|
|
87
|
+
log('info', `Received ${signal}; shutting down.`);
|
|
88
|
+
void transport.stop();
|
|
89
|
+
};
|
|
90
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
91
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
92
|
+
log('info', `Listening for /ramx_* commands via ${config.transport}. Ordinary messages are ignored.`);
|
|
93
|
+
await transport.start(async (message) => {
|
|
94
|
+
// dispatch() owns parsing, the privacy rule and scope enforcement. A
|
|
95
|
+
// transport cannot skip them: handing over text is all it can do.
|
|
96
|
+
const reply = await dispatch(message.text, ctx);
|
|
97
|
+
if (reply === null)
|
|
98
|
+
return;
|
|
99
|
+
log('info', 'Handled command', { transport: message.transport, actor: message.actorRef });
|
|
100
|
+
await message.respond(reply);
|
|
101
|
+
});
|
|
102
|
+
log('info', 'Runtime stopped.');
|
|
103
|
+
}
|
|
104
|
+
main().catch((err) => {
|
|
105
|
+
if (err instanceof ConfigError) {
|
|
106
|
+
log('error', err.message);
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
log('error', 'Runtime crashed', { error: redact(err) });
|
|
110
|
+
process.exit(1);
|
|
111
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord transport — persistent Gateway connection.
|
|
3
|
+
*
|
|
4
|
+
* This is the third transport *shape*: Telegram pulls, Zalo OA and LINE are
|
|
5
|
+
* pushed to over HTTP, and Discord holds a long-lived WebSocket open. Getting
|
|
6
|
+
* all three through one `Transport` interface is the point of the refactor.
|
|
7
|
+
*
|
|
8
|
+
* Implemented against the raw Gateway protocol using Node's built-in
|
|
9
|
+
* WebSocket (Node 22+), rather than pulling in discord.js. A reference example
|
|
10
|
+
* should show the actual protocol and stay dependency-free; discord.js would
|
|
11
|
+
* add tens of megabytes to demonstrate less.
|
|
12
|
+
*
|
|
13
|
+
* Gateway sequence used here:
|
|
14
|
+
* GET /gateway/bot -> wss URL
|
|
15
|
+
* op 10 HELLO -> heartbeat_interval
|
|
16
|
+
* op 1 HEARTBEAT -> every interval, carrying the last sequence
|
|
17
|
+
* op 2 IDENTIFY -> token + intents
|
|
18
|
+
* op 0 DISPATCH -> MESSAGE_CREATE (what we care about)
|
|
19
|
+
* op 11 HEARTBEAT_ACK
|
|
20
|
+
* op 7 RECONNECT / op 9 INVALID_SESSION -> reconnect with backoff
|
|
21
|
+
*
|
|
22
|
+
* MESSAGE_CONTENT is a privileged intent: it must be enabled in the Discord
|
|
23
|
+
* Developer Portal or message text arrives empty. The README says so.
|
|
24
|
+
*
|
|
25
|
+
* The bot token lives only in this process, in an `Authorization: Bot …`
|
|
26
|
+
* header and the IDENTIFY payload. It is registered as a secret so it cannot
|
|
27
|
+
* reach a log.
|
|
28
|
+
*/
|
|
29
|
+
import { log, registerSecret, redact } from '../../core/redact.js';
|
|
30
|
+
import { defaultSleep, } from '../../core/types.js';
|
|
31
|
+
/** GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT */
|
|
32
|
+
export const DISCORD_INTENTS = (1 << 9) | (1 << 12) | (1 << 15);
|
|
33
|
+
export const DiscordOp = {
|
|
34
|
+
DISPATCH: 0,
|
|
35
|
+
HEARTBEAT: 1,
|
|
36
|
+
IDENTIFY: 2,
|
|
37
|
+
RESUME: 6,
|
|
38
|
+
RECONNECT: 7,
|
|
39
|
+
INVALID_SESSION: 9,
|
|
40
|
+
HELLO: 10,
|
|
41
|
+
HEARTBEAT_ACK: 11,
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* True when a MESSAGE_CREATE should be ignored outright.
|
|
45
|
+
*
|
|
46
|
+
* Bot authors are skipped first and always: without this, two instances of
|
|
47
|
+
* this runtime in one channel would answer each other's replies forever, and
|
|
48
|
+
* the runtime would answer itself. That loop guard belongs here rather than in
|
|
49
|
+
* the core, because "is this message from a bot" is a platform concept.
|
|
50
|
+
*/
|
|
51
|
+
export function shouldIgnoreDiscordMessage(msg, selfId) {
|
|
52
|
+
if (!msg.content)
|
|
53
|
+
return true;
|
|
54
|
+
if (msg.author?.bot)
|
|
55
|
+
return true;
|
|
56
|
+
if (selfId && msg.author?.id === selfId)
|
|
57
|
+
return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
/** Strips a leading bot mention so "@bot /ramx_me" parses as a command. */
|
|
61
|
+
export function stripLeadingMention(content, selfId) {
|
|
62
|
+
if (!selfId)
|
|
63
|
+
return content.trim();
|
|
64
|
+
return content.replace(new RegExp(`^\\s*<@!?${selfId}>\\s*`), '').trim();
|
|
65
|
+
}
|
|
66
|
+
export function backoffMs(consecutiveFailures, baseMs = 1000, maxMs = 60_000) {
|
|
67
|
+
return Math.min(baseMs * 2 ** Math.max(0, consecutiveFailures - 1), maxMs);
|
|
68
|
+
}
|
|
69
|
+
export class DiscordTransport {
|
|
70
|
+
name = 'discord';
|
|
71
|
+
config;
|
|
72
|
+
fetchImpl;
|
|
73
|
+
sleep;
|
|
74
|
+
socketFactory;
|
|
75
|
+
maxConnections;
|
|
76
|
+
running = false;
|
|
77
|
+
socket;
|
|
78
|
+
heartbeatTimer;
|
|
79
|
+
lastSequence = null;
|
|
80
|
+
selfId = null;
|
|
81
|
+
constructor(config, deps = {}) {
|
|
82
|
+
this.config = config;
|
|
83
|
+
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
84
|
+
this.sleep = deps.sleep ?? defaultSleep;
|
|
85
|
+
this.socketFactory =
|
|
86
|
+
deps.socketFactory ??
|
|
87
|
+
((url) => new WebSocket(url));
|
|
88
|
+
this.maxConnections = deps.maxConnections;
|
|
89
|
+
registerSecret(config.botToken);
|
|
90
|
+
}
|
|
91
|
+
get apiBase() {
|
|
92
|
+
return this.config.apiBase.replace(/\/+$/, '');
|
|
93
|
+
}
|
|
94
|
+
authHeaders() {
|
|
95
|
+
return {
|
|
96
|
+
Authorization: `Bot ${this.config.botToken}`,
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
'User-Agent': 'DiscordBot (https://ramx.vn, 1.0.0)',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async getGatewayUrl() {
|
|
102
|
+
if (this.config.gatewayUrl)
|
|
103
|
+
return this.config.gatewayUrl;
|
|
104
|
+
const res = await this.fetchImpl(`${this.apiBase}/gateway/bot`, {
|
|
105
|
+
headers: this.authHeaders(),
|
|
106
|
+
signal: AbortSignal.timeout(15_000),
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
throw new Error(`Discord /gateway/bot failed with HTTP ${res.status}`);
|
|
110
|
+
}
|
|
111
|
+
const body = (await res.json());
|
|
112
|
+
if (!body.url)
|
|
113
|
+
throw new Error('Discord /gateway/bot returned no url');
|
|
114
|
+
return `${body.url}?v=10&encoding=json`;
|
|
115
|
+
}
|
|
116
|
+
async getSelfId() {
|
|
117
|
+
try {
|
|
118
|
+
const res = await this.fetchImpl(`${this.apiBase}/users/@me`, {
|
|
119
|
+
headers: this.authHeaders(),
|
|
120
|
+
signal: AbortSignal.timeout(15_000),
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok)
|
|
123
|
+
return null;
|
|
124
|
+
const body = (await res.json());
|
|
125
|
+
return body.id ?? null;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async sendMessage(channelId, text) {
|
|
132
|
+
// Discord caps a message at 2000 characters.
|
|
133
|
+
const safe = text.length > 1900 ? `${text.slice(0, 1890)}\n…(truncated)` : text;
|
|
134
|
+
const res = await this.fetchImpl(`${this.apiBase}/channels/${channelId}/messages`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: this.authHeaders(),
|
|
137
|
+
body: JSON.stringify({ content: safe }),
|
|
138
|
+
signal: AbortSignal.timeout(15_000),
|
|
139
|
+
});
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
throw new Error(`Discord sendMessage failed with HTTP ${res.status}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Handles one decoded gateway payload. Exposed for tests. */
|
|
145
|
+
async handlePayload(payload, handler) {
|
|
146
|
+
if (typeof payload.s === 'number')
|
|
147
|
+
this.lastSequence = payload.s;
|
|
148
|
+
switch (payload.op) {
|
|
149
|
+
case DiscordOp.HELLO: {
|
|
150
|
+
const interval = payload.d?.heartbeat_interval;
|
|
151
|
+
if (interval && interval > 0)
|
|
152
|
+
this.startHeartbeat(interval);
|
|
153
|
+
this.identify();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
case DiscordOp.DISPATCH: {
|
|
157
|
+
if (payload.t === 'READY') {
|
|
158
|
+
const d = payload.d;
|
|
159
|
+
this.selfId = d?.user?.id ?? this.selfId;
|
|
160
|
+
log('info', 'Discord gateway ready');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (payload.t !== 'MESSAGE_CREATE')
|
|
164
|
+
return;
|
|
165
|
+
const msg = payload.d;
|
|
166
|
+
if (shouldIgnoreDiscordMessage(msg, this.selfId))
|
|
167
|
+
return;
|
|
168
|
+
const text = stripLeadingMention(msg.content, this.selfId);
|
|
169
|
+
try {
|
|
170
|
+
await handler({
|
|
171
|
+
transport: this.name,
|
|
172
|
+
text,
|
|
173
|
+
actorRef: `discord:${msg.author?.id ?? 'unknown'}`,
|
|
174
|
+
respond: (reply) => this.sendMessage(msg.channel_id, reply),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
log('error', 'Failed to handle Discord message', { error: redact(err) });
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
case DiscordOp.HEARTBEAT:
|
|
183
|
+
this.sendPayload({ op: DiscordOp.HEARTBEAT, d: this.lastSequence });
|
|
184
|
+
return;
|
|
185
|
+
case DiscordOp.RECONNECT:
|
|
186
|
+
case DiscordOp.INVALID_SESSION:
|
|
187
|
+
log('warn', 'Discord asked us to reconnect', { op: payload.op });
|
|
188
|
+
this.closeSocket();
|
|
189
|
+
return;
|
|
190
|
+
default:
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
identify() {
|
|
195
|
+
this.sendPayload({
|
|
196
|
+
op: DiscordOp.IDENTIFY,
|
|
197
|
+
d: {
|
|
198
|
+
token: this.config.botToken,
|
|
199
|
+
intents: DISCORD_INTENTS,
|
|
200
|
+
properties: { os: process.platform, browser: 'ramx-bot-runtime', device: 'ramx-bot-runtime' },
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
startHeartbeat(intervalMs) {
|
|
205
|
+
this.stopHeartbeat();
|
|
206
|
+
this.heartbeatTimer = setInterval(() => {
|
|
207
|
+
this.sendPayload({ op: DiscordOp.HEARTBEAT, d: this.lastSequence });
|
|
208
|
+
}, intervalMs);
|
|
209
|
+
// Never hold the event loop open just for a heartbeat.
|
|
210
|
+
this.heartbeatTimer.unref?.();
|
|
211
|
+
}
|
|
212
|
+
stopHeartbeat() {
|
|
213
|
+
if (this.heartbeatTimer)
|
|
214
|
+
clearInterval(this.heartbeatTimer);
|
|
215
|
+
this.heartbeatTimer = undefined;
|
|
216
|
+
}
|
|
217
|
+
sendPayload(payload) {
|
|
218
|
+
try {
|
|
219
|
+
this.socket?.send(JSON.stringify(payload));
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
log('warn', 'Discord send failed', { error: redact(err) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
closeSocket() {
|
|
226
|
+
try {
|
|
227
|
+
this.socket?.close(4000);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
/* already gone */
|
|
231
|
+
}
|
|
232
|
+
this.socket = undefined;
|
|
233
|
+
}
|
|
234
|
+
async start(handler) {
|
|
235
|
+
this.running = true;
|
|
236
|
+
let failures = 0;
|
|
237
|
+
let connections = 0;
|
|
238
|
+
this.selfId = await this.getSelfId();
|
|
239
|
+
while (this.running && (this.maxConnections === undefined || connections < this.maxConnections)) {
|
|
240
|
+
connections += 1;
|
|
241
|
+
try {
|
|
242
|
+
const url = await this.getGatewayUrl();
|
|
243
|
+
await this.runConnection(url, handler);
|
|
244
|
+
failures = 0;
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
failures += 1;
|
|
248
|
+
const waitMs = backoffMs(failures);
|
|
249
|
+
log('warn', 'Discord gateway connection failed; backing off', {
|
|
250
|
+
attempt: failures,
|
|
251
|
+
waitMs,
|
|
252
|
+
error: redact(err),
|
|
253
|
+
});
|
|
254
|
+
await this.sleep(waitMs);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
this.stopHeartbeat();
|
|
258
|
+
}
|
|
259
|
+
/** Resolves when the socket closes, so `start()` can reconnect. */
|
|
260
|
+
runConnection(url, handler) {
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
let settled = false;
|
|
263
|
+
const done = (err) => {
|
|
264
|
+
if (settled)
|
|
265
|
+
return;
|
|
266
|
+
settled = true;
|
|
267
|
+
this.stopHeartbeat();
|
|
268
|
+
if (err) {
|
|
269
|
+
reject(err);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
resolve();
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
let socket;
|
|
276
|
+
try {
|
|
277
|
+
socket = this.socketFactory(url);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
done(err instanceof Error ? err : new Error(String(err)));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.socket = socket;
|
|
284
|
+
socket.addEventListener('message', (ev) => {
|
|
285
|
+
void (async () => {
|
|
286
|
+
try {
|
|
287
|
+
const data = ev?.data;
|
|
288
|
+
if (data === undefined)
|
|
289
|
+
return;
|
|
290
|
+
const raw = typeof data === 'string' ? data : String(data);
|
|
291
|
+
await this.handlePayload(JSON.parse(raw), handler);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
log('error', 'Bad Discord gateway payload', { error: redact(err) });
|
|
295
|
+
}
|
|
296
|
+
})();
|
|
297
|
+
});
|
|
298
|
+
socket.addEventListener('close', () => done());
|
|
299
|
+
socket.addEventListener('error', () => done(new Error('Discord gateway socket error')));
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
async stop() {
|
|
303
|
+
this.running = false;
|
|
304
|
+
this.stopHeartbeat();
|
|
305
|
+
this.closeSocket();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LINE Official Account transport — signed webhook.
|
|
3
|
+
*
|
|
4
|
+
* The signature scheme here is taken from LINE's own documentation
|
|
5
|
+
* (developers.line.biz, "Verify webhook signature"):
|
|
6
|
+
*
|
|
7
|
+
* header x-line-signature
|
|
8
|
+
* algorithm HMAC-SHA256
|
|
9
|
+
* key the channel secret
|
|
10
|
+
* data the RAW request body, unmodified and undeserialized
|
|
11
|
+
* encoding Base64
|
|
12
|
+
*
|
|
13
|
+
* LINE is explicit that any modification before verification — reformatting,
|
|
14
|
+
* deserializing, escaping — makes a legitimate request indistinguishable from
|
|
15
|
+
* a tampered one, which is why the shared webhook server hands us raw bytes.
|
|
16
|
+
*
|
|
17
|
+
* The webhook URL you configure in the LINE Developers Console points at the
|
|
18
|
+
* machine running THIS process. It is not a RAM/X endpoint, and RAM/X never
|
|
19
|
+
* sees your channel access token or channel secret.
|
|
20
|
+
*
|
|
21
|
+
* Replies use the `replyToken` from the event. It is single-use and expires
|
|
22
|
+
* quickly, so the reply is sent once, in-band, with no retry.
|
|
23
|
+
*/
|
|
24
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
25
|
+
import { log, registerSecret, redact } from '../../core/redact.js';
|
|
26
|
+
import { WebhookServer } from '../shared/webhook-server.js';
|
|
27
|
+
/**
|
|
28
|
+
* Constant-time comparison of the received signature against the computed one.
|
|
29
|
+
*
|
|
30
|
+
* `timingSafeEqual` throws on length mismatch, so lengths are checked first —
|
|
31
|
+
* and a length mismatch is itself a rejection, not an error.
|
|
32
|
+
*/
|
|
33
|
+
export function verifyLineSignature(rawBody, channelSecret, received) {
|
|
34
|
+
if (!received)
|
|
35
|
+
return false;
|
|
36
|
+
const expected = createHmac('sha256', channelSecret).update(rawBody, 'utf8').digest('base64');
|
|
37
|
+
const a = Buffer.from(expected, 'utf8');
|
|
38
|
+
const b = Buffer.from(received, 'utf8');
|
|
39
|
+
if (a.length !== b.length)
|
|
40
|
+
return false;
|
|
41
|
+
return timingSafeEqual(a, b);
|
|
42
|
+
}
|
|
43
|
+
/** Extracts text message events. Non-text and non-message events are ignored. */
|
|
44
|
+
export function extractTextEvents(body) {
|
|
45
|
+
if (!Array.isArray(body.events))
|
|
46
|
+
return [];
|
|
47
|
+
return body.events.filter((e) => e.type === 'message' && e.message?.type === 'text' && typeof e.message.text === 'string');
|
|
48
|
+
}
|
|
49
|
+
export class LineOfficialTransport {
|
|
50
|
+
name = 'line_official';
|
|
51
|
+
config;
|
|
52
|
+
fetchImpl;
|
|
53
|
+
server;
|
|
54
|
+
constructor(config, deps = {}) {
|
|
55
|
+
this.config = config;
|
|
56
|
+
this.fetchImpl = deps.fetchImpl ?? fetch;
|
|
57
|
+
registerSecret(config.channelAccessToken);
|
|
58
|
+
registerSecret(config.channelSecret);
|
|
59
|
+
}
|
|
60
|
+
/** Sends a reply using a replyToken. Single attempt — the token expires. */
|
|
61
|
+
async reply(replyToken, text) {
|
|
62
|
+
// LINE caps a text message at 5000 characters.
|
|
63
|
+
const safe = text.length > 4900 ? `${text.slice(0, 4890)}\n…(truncated)` : text;
|
|
64
|
+
const res = await this.fetchImpl(`${this.config.apiBase.replace(/\/+$/, '')}/message/reply`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${this.config.channelAccessToken}`,
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({ replyToken, messages: [{ type: 'text', text: safe }] }),
|
|
71
|
+
signal: AbortSignal.timeout(15_000),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw new Error(`LINE reply failed with HTTP ${res.status}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Processes one webhook delivery. Exposed for tests so the whole signature →
|
|
79
|
+
* normalize → dispatch path can run without binding a port.
|
|
80
|
+
*/
|
|
81
|
+
async handleWebhook(rawBody, headers, handler) {
|
|
82
|
+
if (!verifyLineSignature(rawBody, this.config.channelSecret, headers['x-line-signature'])) {
|
|
83
|
+
log('warn', 'Rejected LINE webhook with an invalid signature');
|
|
84
|
+
return { status: 401, body: 'invalid signature' };
|
|
85
|
+
}
|
|
86
|
+
let body;
|
|
87
|
+
try {
|
|
88
|
+
body = JSON.parse(rawBody);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { status: 400, body: 'invalid json' };
|
|
92
|
+
}
|
|
93
|
+
for (const event of extractTextEvents(body)) {
|
|
94
|
+
const replyToken = event.replyToken;
|
|
95
|
+
try {
|
|
96
|
+
await handler({
|
|
97
|
+
transport: this.name,
|
|
98
|
+
text: event.message.text,
|
|
99
|
+
actorRef: `line:${event.source?.userId ?? 'unknown'}`,
|
|
100
|
+
respond: async (text) => {
|
|
101
|
+
if (!replyToken)
|
|
102
|
+
return;
|
|
103
|
+
await this.reply(replyToken, text);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
// One bad event must not fail the whole delivery: LINE would redeliver
|
|
109
|
+
// the entire batch, re-running events that already succeeded.
|
|
110
|
+
log('error', 'Failed to handle LINE event', { error: redact(err) });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// LINE expects 200 once the delivery is accepted.
|
|
114
|
+
return { status: 200, body: 'ok' };
|
|
115
|
+
}
|
|
116
|
+
async start(handler) {
|
|
117
|
+
this.server = new WebhookServer({
|
|
118
|
+
port: this.config.webhookPort,
|
|
119
|
+
path: this.config.webhookPath,
|
|
120
|
+
handler: (req) => this.handleWebhook(req.rawBody, req.headers, handler),
|
|
121
|
+
});
|
|
122
|
+
await this.server.start();
|
|
123
|
+
log('info', 'LINE Official transport ready (signed webhook)', {
|
|
124
|
+
path: this.config.webhookPath,
|
|
125
|
+
port: this.config.webhookPort,
|
|
126
|
+
});
|
|
127
|
+
// Resolves only when stopped — the server owns the lifetime.
|
|
128
|
+
await new Promise((resolve) => {
|
|
129
|
+
this.resolveStopped = resolve;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
resolveStopped;
|
|
133
|
+
async stop() {
|
|
134
|
+
await this.server?.stop();
|
|
135
|
+
this.resolveStopped?.();
|
|
136
|
+
}
|
|
137
|
+
}
|