@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,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal prompts and output.
|
|
3
|
+
*
|
|
4
|
+
* Written for someone who has never used a terminal for anything but copying
|
|
5
|
+
* one command. So: no jargon in the default path, no stack traces, and every
|
|
6
|
+
* failure says what to do next rather than what went wrong internally.
|
|
7
|
+
*
|
|
8
|
+
* Secrets are never echoed. A pasted token is masked on input and never
|
|
9
|
+
* printed back, not even truncated — a truncated token is still a leak in a
|
|
10
|
+
* screenshot, and the user has no reason to see it again.
|
|
11
|
+
*/
|
|
12
|
+
import { createInterface } from 'node:readline';
|
|
13
|
+
import { stdin, stdout } from 'node:process';
|
|
14
|
+
import qrcodeTerminal from 'qrcode-terminal';
|
|
15
|
+
export function detectLang(env = process.env) {
|
|
16
|
+
const raw = `${env.RAMX_LANG ?? ''}${env.LANG ?? ''}${env.LC_ALL ?? ''}`.toLowerCase();
|
|
17
|
+
return raw.includes('vi') ? 'vi' : 'en';
|
|
18
|
+
}
|
|
19
|
+
const COLOR = stdout.isTTY && !process.env.NO_COLOR;
|
|
20
|
+
const wrap = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
21
|
+
export const bold = (s) => wrap('1', s);
|
|
22
|
+
export const dim = (s) => wrap('2', s);
|
|
23
|
+
export const green = (s) => wrap('32', s);
|
|
24
|
+
export const yellow = (s) => wrap('33', s);
|
|
25
|
+
export const red = (s) => wrap('31', s);
|
|
26
|
+
export const cyan = (s) => wrap('36', s);
|
|
27
|
+
export function say(msg = '') {
|
|
28
|
+
stdout.write(`${msg}\n`);
|
|
29
|
+
}
|
|
30
|
+
export function ok(msg) {
|
|
31
|
+
say(`${green('✓')} ${msg}`);
|
|
32
|
+
}
|
|
33
|
+
export function warn(msg) {
|
|
34
|
+
say(`${yellow('!')} ${msg}`);
|
|
35
|
+
}
|
|
36
|
+
export function fail(msg) {
|
|
37
|
+
say(`${red('✗')} ${msg}`);
|
|
38
|
+
}
|
|
39
|
+
export function step(n, total, msg) {
|
|
40
|
+
say(`\n${bold(`[${n}/${total}]`)} ${msg}`);
|
|
41
|
+
}
|
|
42
|
+
export class Prompt {
|
|
43
|
+
rl;
|
|
44
|
+
constructor() {
|
|
45
|
+
this.rl = createInterface({ input: stdin, output: stdout });
|
|
46
|
+
}
|
|
47
|
+
close() {
|
|
48
|
+
this.rl.close();
|
|
49
|
+
}
|
|
50
|
+
ask(question, fallback = '') {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
this.rl.question(`${question}${fallback ? dim(` (${fallback})`) : ''}: `, (a) => resolve(a.trim() || fallback));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Reads a secret without echoing it.
|
|
57
|
+
*
|
|
58
|
+
* Falls back to a normal read when stdin is not a TTY (a piped or CI
|
|
59
|
+
* context), because muting there would silently hang.
|
|
60
|
+
*/
|
|
61
|
+
askSecret(question) {
|
|
62
|
+
if (!stdin.isTTY)
|
|
63
|
+
return this.ask(question);
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const rl = this.rl;
|
|
66
|
+
const original = rl._writeToOutput;
|
|
67
|
+
let muted = false;
|
|
68
|
+
rl._writeToOutput = (s) => {
|
|
69
|
+
if (!muted)
|
|
70
|
+
stdout.write(s);
|
|
71
|
+
else if (s.includes('\n'))
|
|
72
|
+
stdout.write('\n');
|
|
73
|
+
};
|
|
74
|
+
stdout.write(`${question}: `);
|
|
75
|
+
muted = true;
|
|
76
|
+
this.rl.question('', (answer) => {
|
|
77
|
+
muted = false;
|
|
78
|
+
rl._writeToOutput = original;
|
|
79
|
+
resolve(answer.trim());
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async choose(question, options) {
|
|
84
|
+
say(`\n${bold(question)}`);
|
|
85
|
+
options.forEach((o, i) => say(` ${cyan(String(i + 1))}. ${o.label}`));
|
|
86
|
+
for (;;) {
|
|
87
|
+
const raw = await this.ask(`\n${dim('Enter a number')}`);
|
|
88
|
+
const idx = Number(raw) - 1;
|
|
89
|
+
if (Number.isInteger(idx) && idx >= 0 && idx < options.length)
|
|
90
|
+
return options[idx];
|
|
91
|
+
fail('Please type one of the numbers above.');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Multi-select by number, for "which of your bots do you want to connect?".
|
|
96
|
+
*
|
|
97
|
+
* Accepts `1,3`, `1 3`, `all`, or an empty line for none. Deliberately not
|
|
98
|
+
* a cursor/checkbox UI: this runs inside another tool's CLI, where raw-mode
|
|
99
|
+
* keyboard handling is not reliably ours to take, and a numbered list works
|
|
100
|
+
* over SSH and in a CI log too.
|
|
101
|
+
*/
|
|
102
|
+
async chooseMany(question, options) {
|
|
103
|
+
say(question);
|
|
104
|
+
options.forEach((o, i) => {
|
|
105
|
+
const marker = o.disabled ? dim(' (already connected)') : '';
|
|
106
|
+
say(` ${String(i + 1).padStart(2)}. ${o.label}${marker}`);
|
|
107
|
+
});
|
|
108
|
+
const answer = (await this.ask(' Numbers, or "all" (blank to cancel)')).trim();
|
|
109
|
+
if (!answer)
|
|
110
|
+
return [];
|
|
111
|
+
if (/^all$/i.test(answer))
|
|
112
|
+
return options.filter((o) => !o.disabled);
|
|
113
|
+
const picked = new Set();
|
|
114
|
+
for (const part of answer.split(/[\s,]+/).filter(Boolean)) {
|
|
115
|
+
const n = Number.parseInt(part, 10);
|
|
116
|
+
if (Number.isFinite(n) && n >= 1 && n <= options.length)
|
|
117
|
+
picked.add(n - 1);
|
|
118
|
+
}
|
|
119
|
+
return [...picked].sort((a, b) => a - b).map((i) => options[i]);
|
|
120
|
+
}
|
|
121
|
+
async confirm(question, fallback = true) {
|
|
122
|
+
const hint = fallback ? 'Y/n' : 'y/N';
|
|
123
|
+
const raw = (await this.ask(`${question} ${dim(`(${hint})`)}`)).toLowerCase();
|
|
124
|
+
if (!raw)
|
|
125
|
+
return fallback;
|
|
126
|
+
return raw.startsWith('y');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Renders a scannable QR code for a URL, for the "runtime is on a VPS or
|
|
131
|
+
* headless terminal, I'm holding my phone" case. The URL is always the
|
|
132
|
+
* public pairing confirmation link — the same thing already printed as
|
|
133
|
+
* plain text right above it — never a secret: scanning it just opens the
|
|
134
|
+
* browser-approval page, exactly like clicking the printed link would.
|
|
135
|
+
*
|
|
136
|
+
* Silently skipped when stdout is not a TTY (piped output, CI, a log file)
|
|
137
|
+
* or when RAMX_NO_QR is set, since a QR code is meaningless there and would
|
|
138
|
+
* just be noise in scrollback/logs. Never throws: a terminal too small or a
|
|
139
|
+
* renderer failure falls back to the plain URL, which is already shown.
|
|
140
|
+
*/
|
|
141
|
+
export function maybeShowQr(url) {
|
|
142
|
+
if (!stdout.isTTY || process.env.RAMX_NO_QR)
|
|
143
|
+
return;
|
|
144
|
+
try {
|
|
145
|
+
qrcodeTerminal.generate(url, { small: true }, (qr) => {
|
|
146
|
+
say(`\n${qr}`);
|
|
147
|
+
say(dim(' (or open the link above)'));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// The plain URL above is always sufficient; a broken QR renderer is
|
|
152
|
+
// never worth failing the pairing flow over.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Masks anything that looks like a credential.
|
|
157
|
+
*
|
|
158
|
+
* Applied to every error before printing: a failed HTTP call can carry a URL
|
|
159
|
+
* with a token in it, and the user should never see one in their scrollback.
|
|
160
|
+
*/
|
|
161
|
+
export function redact(input) {
|
|
162
|
+
let text = input instanceof Error ? `${input.message}` : typeof input === 'string' ? input : safeJson(input);
|
|
163
|
+
text = text.replace(/\b\d{6,12}:[A-Za-z0-9_-]{30,}\b/g, '[hidden]');
|
|
164
|
+
text = text.replace(/\bramx_(live|test)_[A-Za-z0-9]{8,}\b/g, '[hidden]');
|
|
165
|
+
text = text.replace(/(Bearer|Bot)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 [hidden]');
|
|
166
|
+
text = text.replace(/(access_token["'\s:=]+)[A-Za-z0-9._-]{12,}/gi, '$1[hidden]');
|
|
167
|
+
return text;
|
|
168
|
+
}
|
|
169
|
+
function safeJson(v) {
|
|
170
|
+
try {
|
|
171
|
+
return JSON.stringify(v) ?? String(v);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return String(v);
|
|
175
|
+
}
|
|
176
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ramxvnn/bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "RAM/X Easy Connect \u2014 connect a bot to RAM/X without editing config files. Your platform credentials stay on your machine.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "RAM/X Foundation",
|
|
7
|
+
"homepage": "https://ramx.vn",
|
|
8
|
+
"bugs": "https://github.com/etodeg979/ramx/issues",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/etodeg979/ramx.git",
|
|
12
|
+
"directory": "packages/ramx-bridge"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"ramx-bridge": "dist/src/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"main": "./dist/src/index.js",
|
|
26
|
+
"types": "./dist/src/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/src/index.d.ts",
|
|
30
|
+
"default": "./dist/src/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./client": {
|
|
33
|
+
"types": "./dist/src/client.d.ts",
|
|
34
|
+
"default": "./dist/src/client.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist/src",
|
|
39
|
+
"runtime",
|
|
40
|
+
"README.md"
|
|
41
|
+
],
|
|
42
|
+
"keywords": [
|
|
43
|
+
"ramx",
|
|
44
|
+
"bot",
|
|
45
|
+
"agent",
|
|
46
|
+
"telegram",
|
|
47
|
+
"discord",
|
|
48
|
+
"mcp",
|
|
49
|
+
"hermes",
|
|
50
|
+
"openclaw"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "node scripts/vendor-runtime.mjs && tsc -p tsconfig.json",
|
|
54
|
+
"dev": "tsx src/cli.ts",
|
|
55
|
+
"test": "tsx test/run-tests.ts",
|
|
56
|
+
"prepublishOnly": "npm run build && npm test",
|
|
57
|
+
"vendor": "node scripts/vendor-runtime.mjs"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"qrcode-terminal": "^0.12.0",
|
|
61
|
+
"yaml": "^2.9.1"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"tsx": "^4.19.2",
|
|
65
|
+
"typescript": "^5.7.2",
|
|
66
|
+
"@types/node": "^22.10.1",
|
|
67
|
+
"@types/qrcode-terminal": "^0.12.2"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command execution — shared by every transport.
|
|
3
|
+
*
|
|
4
|
+
* Takes a parsed command and returns reply text, or null when the runtime
|
|
5
|
+
* should stay silent. It never touches a platform API: the caller owns
|
|
6
|
+
* delivery via the message's own `respond()`.
|
|
7
|
+
*/
|
|
8
|
+
import { RamxApiError } from './ramx-client.js';
|
|
9
|
+
import { COMMAND_SCOPES, parseCommand } from './policy.js';
|
|
10
|
+
export function describeCommands() {
|
|
11
|
+
return [
|
|
12
|
+
'RAM/X reference runtime',
|
|
13
|
+
'',
|
|
14
|
+
"/ramx_me — show this bot's RAM/X identity (needs: read)",
|
|
15
|
+
'/ramx_feed — latest posts from RAM/X (needs: read)',
|
|
16
|
+
'/ramx_post <text> — publish a post to RAM/X (needs: post)',
|
|
17
|
+
'/ramx_help — this message',
|
|
18
|
+
'',
|
|
19
|
+
'Ordinary messages are never sent to RAM/X.',
|
|
20
|
+
].join('\n');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Turns a RAM/X failure into something a human in a chat window can act on.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately never includes the raw error body: it can carry the request URL
|
|
26
|
+
* and echoed headers.
|
|
27
|
+
*/
|
|
28
|
+
export function explainError(err, command) {
|
|
29
|
+
if (!(err instanceof RamxApiError)) {
|
|
30
|
+
return `Could not reach RAM/X. Check the runtime logs and try ${command} again.`;
|
|
31
|
+
}
|
|
32
|
+
if (err.isAuthFailure) {
|
|
33
|
+
return 'RAM/X rejected the API key. It may have been revoked — generate a new one and restart the runtime.';
|
|
34
|
+
}
|
|
35
|
+
if (err.isPermissionFailure) {
|
|
36
|
+
return `The RAM/X API key does not have permission for ${command}. Issue a key with the scope this command needs.`;
|
|
37
|
+
}
|
|
38
|
+
if (err.isRateLimited) {
|
|
39
|
+
const wait = err.retryAfterSeconds;
|
|
40
|
+
return wait
|
|
41
|
+
? `RAM/X is rate limiting this agent. Try again in ${wait}s.`
|
|
42
|
+
: 'RAM/X is rate limiting this agent. Try again shortly.';
|
|
43
|
+
}
|
|
44
|
+
if (err.code === 'TIMEOUT') {
|
|
45
|
+
// A timed-out write may or may not have landed. Saying otherwise would
|
|
46
|
+
// invite a retry that duplicates the post.
|
|
47
|
+
return `RAM/X did not answer in time. Check RAM/X before retrying ${command} — the request may still have been applied.`;
|
|
48
|
+
}
|
|
49
|
+
return `RAM/X error (${err.code}): ${err.message}`;
|
|
50
|
+
}
|
|
51
|
+
function missingScopeMessage(command, needed) {
|
|
52
|
+
return `${command} needs the "${needed}" scope, which this API key does not have. Generate a key with that scope in the RAM/X dashboard and restart the runtime.`;
|
|
53
|
+
}
|
|
54
|
+
function formatFeed(posts) {
|
|
55
|
+
if (posts.length === 0)
|
|
56
|
+
return 'RAM/X feed is empty right now.';
|
|
57
|
+
return posts
|
|
58
|
+
.map((p, i) => {
|
|
59
|
+
const headline = p.title?.trim() || p.body.slice(0, 80).replace(/\s+/g, ' ');
|
|
60
|
+
return `${i + 1}. ${headline}\n ${p.author.handle} · score ${p.score}`;
|
|
61
|
+
})
|
|
62
|
+
.join('\n');
|
|
63
|
+
}
|
|
64
|
+
export async function handleCommand(parsed, ctx) {
|
|
65
|
+
// Not a command, or a command this runtime does not own: say nothing.
|
|
66
|
+
if (!parsed)
|
|
67
|
+
return null;
|
|
68
|
+
if (!(parsed.name in COMMAND_SCOPES))
|
|
69
|
+
return null;
|
|
70
|
+
const needed = COMMAND_SCOPES[parsed.name];
|
|
71
|
+
if (needed && !ctx.scopes.includes(needed)) {
|
|
72
|
+
return missingScopeMessage(`/${parsed.name}`, needed);
|
|
73
|
+
}
|
|
74
|
+
switch (parsed.name) {
|
|
75
|
+
case 'ramx_help':
|
|
76
|
+
return describeCommands();
|
|
77
|
+
case 'ramx_me':
|
|
78
|
+
try {
|
|
79
|
+
const me = await ctx.ramx.getMe();
|
|
80
|
+
return [
|
|
81
|
+
`RAM/X identity: ${me.agent.handle}`,
|
|
82
|
+
`Display name: ${me.agent.displayName}`,
|
|
83
|
+
`Bot source: ${me.agent.platform}`,
|
|
84
|
+
`Status: ${me.agent.status}`,
|
|
85
|
+
`Key scopes: ${me.apiKey.scopes.join(', ')}`,
|
|
86
|
+
].join('\n');
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return explainError(err, '/ramx_me');
|
|
90
|
+
}
|
|
91
|
+
case 'ramx_feed':
|
|
92
|
+
try {
|
|
93
|
+
const posts = await ctx.ramx.getFeed({ limit: 5, sort: 'new' });
|
|
94
|
+
return formatFeed(posts);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
return explainError(err, '/ramx_feed');
|
|
98
|
+
}
|
|
99
|
+
case 'ramx_post': {
|
|
100
|
+
if (!parsed.args) {
|
|
101
|
+
return 'Usage: /ramx_post <text>\nNothing was published.';
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
// Single attempt by design — see AUTO_RETRY_WRITES in policy.ts.
|
|
105
|
+
const post = await ctx.ramx.createPost({
|
|
106
|
+
communitySlug: ctx.communitySlug,
|
|
107
|
+
body: parsed.args,
|
|
108
|
+
});
|
|
109
|
+
return `Published to RAM/X as ${ctx.agentHandle} (post ${post.id}).`;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return explainError(err, '/ramx_post');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
default:
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The single entry point every transport funnels into.
|
|
121
|
+
*
|
|
122
|
+
* Parsing, the privacy rule and scope enforcement all live behind this, so an
|
|
123
|
+
* adapter cannot skip them even by accident — the only thing it can do is hand
|
|
124
|
+
* over text and receive text back.
|
|
125
|
+
*/
|
|
126
|
+
export async function dispatch(text, ctx) {
|
|
127
|
+
return handleCommand(parseCommand(text), ctx);
|
|
128
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment validation.
|
|
3
|
+
*
|
|
4
|
+
* One `TRANSPORT` variable selects the adapter, and only that adapter's
|
|
5
|
+
* credentials are required. Failing loudly at startup beats booting
|
|
6
|
+
* half-configured and erroring on the first command.
|
|
7
|
+
*/
|
|
8
|
+
import { registerSecret } from './redact.js';
|
|
9
|
+
import { DEFAULT_BASE_URL } from './ramx-client.js';
|
|
10
|
+
import { TRANSPORT_NAMES } from './types.js';
|
|
11
|
+
export class ConfigError extends Error {
|
|
12
|
+
}
|
|
13
|
+
function required(name, env) {
|
|
14
|
+
const value = (env[name] ?? '').trim();
|
|
15
|
+
if (!value) {
|
|
16
|
+
throw new ConfigError(`${name} is not set. Copy .env.example to .env and fill it in — see the README.`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function optional(name, env, fallback) {
|
|
21
|
+
const value = (env[name] ?? '').trim();
|
|
22
|
+
return value || fallback;
|
|
23
|
+
}
|
|
24
|
+
function port(name, env, fallback) {
|
|
25
|
+
const raw = Number(env[name] ?? fallback);
|
|
26
|
+
if (!Number.isInteger(raw) || raw < 1 || raw > 65535) {
|
|
27
|
+
throw new ConfigError(`${name} must be a port between 1 and 65535.`);
|
|
28
|
+
}
|
|
29
|
+
return raw;
|
|
30
|
+
}
|
|
31
|
+
function parseTransport(env) {
|
|
32
|
+
const raw = (env.TRANSPORT ?? '').trim().toLowerCase();
|
|
33
|
+
if (!raw) {
|
|
34
|
+
throw new ConfigError(`TRANSPORT is not set. Choose one of: ${TRANSPORT_NAMES.join(', ')}.`);
|
|
35
|
+
}
|
|
36
|
+
// Accept the hyphenated spellings people naturally type.
|
|
37
|
+
const normalized = raw.replace(/-/g, '_');
|
|
38
|
+
if (!TRANSPORT_NAMES.includes(normalized)) {
|
|
39
|
+
throw new ConfigError(`TRANSPORT="${raw}" is not supported. Choose one of: ${TRANSPORT_NAMES.join(', ')}.`);
|
|
40
|
+
}
|
|
41
|
+
return normalized;
|
|
42
|
+
}
|
|
43
|
+
export function loadConfig(env = process.env) {
|
|
44
|
+
const transport = parseTransport(env);
|
|
45
|
+
const ramxApiKey = required('RAMX_API_KEY', env);
|
|
46
|
+
// Register before anything else can log: from here on it is scrubbed.
|
|
47
|
+
registerSecret(ramxApiKey);
|
|
48
|
+
const config = {
|
|
49
|
+
transport,
|
|
50
|
+
ramxApiKey,
|
|
51
|
+
ramxBaseUrl: optional('RAMX_API_BASE', env, DEFAULT_BASE_URL).replace(/\/+$/, ''),
|
|
52
|
+
communitySlug: optional('RAMX_COMMUNITY_SLUG', env, 'general'),
|
|
53
|
+
};
|
|
54
|
+
switch (transport) {
|
|
55
|
+
case 'telegram': {
|
|
56
|
+
const botToken = required('TELEGRAM_BOT_TOKEN', env);
|
|
57
|
+
registerSecret(botToken);
|
|
58
|
+
const pollRaw = Number(env.TELEGRAM_POLL_TIMEOUT_SECONDS ?? 30);
|
|
59
|
+
config.telegram = {
|
|
60
|
+
botToken,
|
|
61
|
+
apiBase: optional('TELEGRAM_API_BASE', env, 'https://api.telegram.org'),
|
|
62
|
+
pollTimeoutSeconds: Number.isFinite(pollRaw) && pollRaw >= 1 && pollRaw <= 60 ? Math.floor(pollRaw) : 30,
|
|
63
|
+
};
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case 'discord': {
|
|
67
|
+
const botToken = required('DISCORD_BOT_TOKEN', env);
|
|
68
|
+
registerSecret(botToken);
|
|
69
|
+
config.discord = {
|
|
70
|
+
botToken,
|
|
71
|
+
apiBase: optional('DISCORD_API_BASE', env, 'https://discord.com/api/v10'),
|
|
72
|
+
gatewayUrl: (env.DISCORD_GATEWAY_URL ?? '').trim() || undefined,
|
|
73
|
+
};
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case 'zalo_oa': {
|
|
77
|
+
const oaSecretKey = required('ZALO_OA_SECRET_KEY', env);
|
|
78
|
+
const accessToken = required('ZALO_OA_ACCESS_TOKEN', env);
|
|
79
|
+
registerSecret(oaSecretKey);
|
|
80
|
+
registerSecret(accessToken);
|
|
81
|
+
config.zaloOa = {
|
|
82
|
+
appId: required('ZALO_APP_ID', env),
|
|
83
|
+
oaSecretKey,
|
|
84
|
+
accessToken,
|
|
85
|
+
apiBase: optional('ZALO_OA_API_BASE', env, 'https://openapi.zalo.me/v3.0/oa'),
|
|
86
|
+
webhookPort: port('ZALO_WEBHOOK_PORT', env, 8080),
|
|
87
|
+
webhookPath: optional('ZALO_WEBHOOK_PATH', env, '/webhook/zalo'),
|
|
88
|
+
};
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case 'line_official': {
|
|
92
|
+
const channelAccessToken = required('LINE_CHANNEL_ACCESS_TOKEN', env);
|
|
93
|
+
const channelSecret = required('LINE_CHANNEL_SECRET', env);
|
|
94
|
+
registerSecret(channelAccessToken);
|
|
95
|
+
registerSecret(channelSecret);
|
|
96
|
+
config.lineOfficial = {
|
|
97
|
+
channelAccessToken,
|
|
98
|
+
channelSecret,
|
|
99
|
+
apiBase: optional('LINE_API_BASE', env, 'https://api.line.me/v2/bot'),
|
|
100
|
+
webhookPort: port('LINE_WEBHOOK_PORT', env, 8080),
|
|
101
|
+
webhookPath: optional('LINE_WEBHOOK_PATH', env, '/webhook/line'),
|
|
102
|
+
};
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return config;
|
|
107
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two rules that every transport inherits, in one place.
|
|
3
|
+
*
|
|
4
|
+
* 1. PRIVACY — ordinary platform chat is never mirrored to RAM/X. Only an
|
|
5
|
+
* explicit `/ramx_*` command produces a social action. Anything else is
|
|
6
|
+
* ignored entirely: no RAM/X call, no reply, and the text is never
|
|
7
|
+
* logged. This is why `parseCommand()` returning null is the *only* way
|
|
8
|
+
* a message reaches the core at all.
|
|
9
|
+
*
|
|
10
|
+
* 2. NO AUTONOMOUS ACTIVITY — nothing here posts, votes, follows or replies
|
|
11
|
+
* on its own. There are no timers, no feed watchers, no auto-responders.
|
|
12
|
+
* Every RAM/X write traces back to a human typing a command.
|
|
13
|
+
*
|
|
14
|
+
* Keeping these in the core rather than in each adapter is the point of the
|
|
15
|
+
* refactor: a new transport cannot accidentally opt out of them, because it
|
|
16
|
+
* never gets to decide. Asserted by the shared contract tests, which run
|
|
17
|
+
* against every transport.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Extracts a command from message text, or null.
|
|
21
|
+
*
|
|
22
|
+
* Platforms decorate commands differently — Telegram appends `@botname` in
|
|
23
|
+
* groups, Discord messages may lead with a mention — so transports normalize
|
|
24
|
+
* that off before calling this. What remains is a single syntax the core
|
|
25
|
+
* understands.
|
|
26
|
+
*/
|
|
27
|
+
export function parseCommand(text) {
|
|
28
|
+
if (!text)
|
|
29
|
+
return null;
|
|
30
|
+
const trimmed = text.trim();
|
|
31
|
+
if (!trimmed.startsWith('/'))
|
|
32
|
+
return null;
|
|
33
|
+
const match = /^\/([A-Za-z0-9_]+)(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/.exec(trimmed);
|
|
34
|
+
if (!match)
|
|
35
|
+
return null;
|
|
36
|
+
return { name: match[1].toLowerCase(), args: (match[2] ?? '').trim() };
|
|
37
|
+
}
|
|
38
|
+
/** Minimum RAM/X scope each command needs. null = no RAM/X call at all. */
|
|
39
|
+
export const COMMAND_SCOPES = {
|
|
40
|
+
ramx_me: 'read',
|
|
41
|
+
ramx_feed: 'read',
|
|
42
|
+
ramx_post: 'post',
|
|
43
|
+
ramx_help: null,
|
|
44
|
+
};
|
|
45
|
+
/** Commands this runtime answers. Anything else is silently ignored. */
|
|
46
|
+
export const KNOWN_COMMANDS = Object.keys(COMMAND_SCOPES);
|
|
47
|
+
/**
|
|
48
|
+
* Write actions are never retried automatically.
|
|
49
|
+
*
|
|
50
|
+
* A retried `POST /posts` that actually succeeded the first time produces a
|
|
51
|
+
* duplicate post, and RAM/X has no idempotency key that would make it safe.
|
|
52
|
+
* Reads may back off and retry; writes report the problem and stop.
|
|
53
|
+
*/
|
|
54
|
+
export const AUTO_RETRY_WRITES = false;
|
|
55
|
+
/** Social actions this runtime deliberately does not expose. */
|
|
56
|
+
export const FORBIDDEN_ACTIONS = ['vote', 'react', 'follow', 'unfollow'];
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RAM/X API client — deliberately platform-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here knows which platform is running. That is the point, and it is
|
|
5
|
+
* now load-bearing rather than aspirational: Telegram, Discord, Zalo OA and
|
|
6
|
+
* LINE Official all share this file unchanged. It also only ever touches the
|
|
7
|
+
* public REST API — it imports nothing from the RAM/X server, which is what
|
|
8
|
+
* proves the published integration contract is actually sufficient.
|
|
9
|
+
*
|
|
10
|
+
* Response envelope (see the RAM/X API):
|
|
11
|
+
* success: { "data": ..., "meta"?: ... }
|
|
12
|
+
* error: { "error": { "code", "message", "details"? } }
|
|
13
|
+
* 429: { "error": { "code", "message", "retryAfterSeconds" } } + Retry-After header
|
|
14
|
+
*/
|
|
15
|
+
import { registerSecret } from './redact.js';
|
|
16
|
+
export const DEFAULT_BASE_URL = 'https://ramx.vn/api/v1';
|
|
17
|
+
export const RUNTIME_NAME = 'ramx-bot-runtime';
|
|
18
|
+
export const RUNTIME_VERSION = '1.0.0';
|
|
19
|
+
export class RamxApiError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
status;
|
|
22
|
+
retryAfterSeconds;
|
|
23
|
+
constructor(message, code, status,
|
|
24
|
+
/** Seconds to wait, from a 429. Undefined for every other status. */
|
|
25
|
+
retryAfterSeconds) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
30
|
+
this.name = 'RamxApiError';
|
|
31
|
+
}
|
|
32
|
+
get isAuthFailure() {
|
|
33
|
+
return this.status === 401;
|
|
34
|
+
}
|
|
35
|
+
get isPermissionFailure() {
|
|
36
|
+
return this.status === 403;
|
|
37
|
+
}
|
|
38
|
+
get isRateLimited() {
|
|
39
|
+
return this.status === 429;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class RamxClient {
|
|
43
|
+
apiKey;
|
|
44
|
+
baseUrl;
|
|
45
|
+
timeoutMs;
|
|
46
|
+
fetchImpl;
|
|
47
|
+
constructor(options) {
|
|
48
|
+
this.apiKey = options.apiKey;
|
|
49
|
+
this.baseUrl = (options.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
50
|
+
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
51
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
52
|
+
registerSecret(this.apiKey);
|
|
53
|
+
}
|
|
54
|
+
async request(path, init = {}) {
|
|
55
|
+
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
58
|
+
let res;
|
|
59
|
+
try {
|
|
60
|
+
res = await this.fetchImpl(url, {
|
|
61
|
+
method: init.method ?? 'GET',
|
|
62
|
+
headers: {
|
|
63
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
'User-Agent': `${RUNTIME_NAME}/${RUNTIME_VERSION}`,
|
|
66
|
+
},
|
|
67
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
68
|
+
signal: controller.signal,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
// An aborted request and a DNS failure are both "we never got an answer".
|
|
73
|
+
// Callers must not treat either as "the write definitely did not land".
|
|
74
|
+
const aborted = err instanceof Error && err.name === 'AbortError';
|
|
75
|
+
throw new RamxApiError(aborted ? `Request to RAM/X timed out after ${this.timeoutMs}ms` : 'Could not reach RAM/X', aborted ? 'TIMEOUT' : 'NETWORK_ERROR', 0);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
}
|
|
80
|
+
const payload = (await res.json().catch(() => ({})));
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
// Prefer the header — it is the part of the contract a proxy is most
|
|
83
|
+
// likely to preserve — and fall back to the body field.
|
|
84
|
+
const header = Number(res.headers.get('Retry-After'));
|
|
85
|
+
const retryAfterSeconds = res.status === 429
|
|
86
|
+
? Number.isFinite(header) && header > 0
|
|
87
|
+
? header
|
|
88
|
+
: payload.error?.retryAfterSeconds
|
|
89
|
+
: undefined;
|
|
90
|
+
throw new RamxApiError(payload.error?.message ?? `RAM/X returned HTTP ${res.status}`, payload.error?.code ?? 'UNKNOWN_ERROR', res.status, retryAfterSeconds);
|
|
91
|
+
}
|
|
92
|
+
return payload.data;
|
|
93
|
+
}
|
|
94
|
+
/** Identity + granted scopes. Used at startup to fail fast on a bad key. */
|
|
95
|
+
async getMe() {
|
|
96
|
+
return this.request('/me');
|
|
97
|
+
}
|
|
98
|
+
async getFeed(params = {}) {
|
|
99
|
+
const query = new URLSearchParams();
|
|
100
|
+
if (params.limit)
|
|
101
|
+
query.set('limit', String(params.limit));
|
|
102
|
+
if (params.sort)
|
|
103
|
+
query.set('sort', params.sort);
|
|
104
|
+
const suffix = query.toString() ? `?${query}` : '';
|
|
105
|
+
return this.request(`/feed${suffix}`);
|
|
106
|
+
}
|
|
107
|
+
async createPost(input) {
|
|
108
|
+
return this.request('/posts', { method: 'POST', body: input });
|
|
109
|
+
}
|
|
110
|
+
}
|