@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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/dist/src/cli.d.ts +9 -0
  4. package/dist/src/cli.js +85 -0
  5. package/dist/src/client.d.ts +37 -0
  6. package/dist/src/client.js +36 -0
  7. package/dist/src/commands/doctor.d.ts +19 -0
  8. package/dist/src/commands/doctor.js +175 -0
  9. package/dist/src/commands/hermes.d.ts +33 -0
  10. package/dist/src/commands/hermes.js +197 -0
  11. package/dist/src/commands/init.d.ts +9 -0
  12. package/dist/src/commands/init.js +138 -0
  13. package/dist/src/commands/mcp.d.ts +34 -0
  14. package/dist/src/commands/mcp.js +210 -0
  15. package/dist/src/commands/pair.d.ts +7 -0
  16. package/dist/src/commands/pair.js +77 -0
  17. package/dist/src/commands/revoke.d.ts +10 -0
  18. package/dist/src/commands/revoke.js +62 -0
  19. package/dist/src/commands/run.d.ts +22 -0
  20. package/dist/src/commands/run.js +139 -0
  21. package/dist/src/index.d.ts +20 -0
  22. package/dist/src/index.js +29 -0
  23. package/dist/src/lib/bindings.d.ts +115 -0
  24. package/dist/src/lib/bindings.js +177 -0
  25. package/dist/src/lib/config.d.ts +80 -0
  26. package/dist/src/lib/config.js +174 -0
  27. package/dist/src/lib/connect-agent.d.ts +74 -0
  28. package/dist/src/lib/connect-agent.js +140 -0
  29. package/dist/src/lib/frameworks.d.ts +92 -0
  30. package/dist/src/lib/frameworks.js +155 -0
  31. package/dist/src/lib/hermes-config.d.ts +100 -0
  32. package/dist/src/lib/hermes-config.js +151 -0
  33. package/dist/src/lib/mcp-tools.d.ts +54 -0
  34. package/dist/src/lib/mcp-tools.js +133 -0
  35. package/dist/src/lib/pair-flow.d.ts +32 -0
  36. package/dist/src/lib/pair-flow.js +70 -0
  37. package/dist/src/lib/ramx.d.ts +205 -0
  38. package/dist/src/lib/ramx.js +212 -0
  39. package/dist/src/lib/trial.d.ts +40 -0
  40. package/dist/src/lib/trial.js +80 -0
  41. package/dist/src/lib/ui.d.ts +80 -0
  42. package/dist/src/lib/ui.js +176 -0
  43. package/package.json +69 -0
  44. package/runtime/VENDORED.md +4 -0
  45. package/runtime/core/commands.js +128 -0
  46. package/runtime/core/config.js +107 -0
  47. package/runtime/core/policy.js +56 -0
  48. package/runtime/core/ramx-client.js +110 -0
  49. package/runtime/core/redact.js +76 -0
  50. package/runtime/core/types.js +25 -0
  51. package/runtime/main.js +111 -0
  52. package/runtime/transports/discord/index.js +307 -0
  53. package/runtime/transports/line-official/index.js +137 -0
  54. package/runtime/transports/shared/webhook-server.js +101 -0
  55. package/runtime/transports/telegram/index.js +150 -0
  56. 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));
@@ -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
+ }