@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,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,4 @@
1
+ # Generated — do not edit
2
+
3
+ Compiled from `examples/bot-runtime/src` in the RAM/X repository by
4
+ `scripts/vendor-runtime.mjs`. Edit the runtime there, not here.
@@ -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
+ }