beeperbox 0.7.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/README.md +49 -0
- package/package.json +41 -0
- package/server.js +1306 -0
package/server.js
ADDED
|
@@ -0,0 +1,1306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// beeperbox MCP server — POC phase 1
|
|
3
|
+
// Single-file, vanilla Node, zero deps. Requires Node 18+ for global fetch.
|
|
4
|
+
//
|
|
5
|
+
// Speaks Model Context Protocol over HTTP transport (POST JSON-RPC 2.0).
|
|
6
|
+
// Stdio transport will be added once HTTP is solid.
|
|
7
|
+
|
|
8
|
+
const http = require('http');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
// Single source of truth for the version: the sibling package.json — so the
|
|
14
|
+
// lite-mode npm package, the container (which COPYs this dir to /opt/mcp), and
|
|
15
|
+
// serverInfo can never report different versions. Falls back only if the
|
|
16
|
+
// manifest is somehow absent (e.g. server.js copied alone).
|
|
17
|
+
const VERSION = (() => {
|
|
18
|
+
try { return require('./package.json').version; } catch { return '0.0.0-dev'; }
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
const PORT = parseInt(process.env.MCP_PORT || '23375', 10);
|
|
22
|
+
const BEEPER_API = process.env.BEEPER_API || 'http://127.0.0.1:23373';
|
|
23
|
+
const BEEPER_TOKEN = process.env.BEEPER_TOKEN || '';
|
|
24
|
+
|
|
25
|
+
// ─── http transport hardening ─────────────────────────────────────
|
|
26
|
+
// The HTTP transport is the network-exposed surface (stdio is local-only).
|
|
27
|
+
// Three guards, all configurable so they don't break the documented
|
|
28
|
+
// loopback publish or a reverse-proxy deployment:
|
|
29
|
+
// MCP_AUTH_TOKEN — if set, every HTTP request must send
|
|
30
|
+
// `Authorization: Bearer <token>`; unset = open
|
|
31
|
+
// (back-compat; relies on the loopback publish).
|
|
32
|
+
// MCP_ALLOWED_HOSTS — Host/Origin allowlist (comma-separated). Blocks
|
|
33
|
+
// DNS-rebinding and cross-origin browser access.
|
|
34
|
+
// Defaults to loopback; set it for reverse proxies.
|
|
35
|
+
// MCP_MAX_BODY — max request body bytes (default 1 MiB) so a large
|
|
36
|
+
// POST can't grow the in-memory buffer unbounded.
|
|
37
|
+
// The listener stays bound to 0.0.0.0 ON PURPOSE: a Docker published port
|
|
38
|
+
// is unreachable if the in-container process binds 127.0.0.1, so loopback
|
|
39
|
+
// binding here would break `127.0.0.1:23375:23375`. Auth + Host/Origin
|
|
40
|
+
// checks are the defense, not the bind address.
|
|
41
|
+
const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN || '';
|
|
42
|
+
const MCP_ALLOWED_HOSTS = new Set(
|
|
43
|
+
(process.env.MCP_ALLOWED_HOSTS || 'localhost,127.0.0.1,::1,[::1]')
|
|
44
|
+
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
|
45
|
+
);
|
|
46
|
+
const MCP_MAX_BODY = parseInt(process.env.MCP_MAX_BODY || String(1024 * 1024), 10);
|
|
47
|
+
|
|
48
|
+
// Echo-guard id resolution. A send returns a `pendingMessageID`, but Beeper
|
|
49
|
+
// swaps it for the real bridge id once the message is acked — so the id we'd
|
|
50
|
+
// record at send time never matches the id the same message reappears under in
|
|
51
|
+
// poll_messages / read_chat. We resolve it: GET the message back until its id
|
|
52
|
+
// differs from the pending one, then store the FINAL id so the echo-guard
|
|
53
|
+
// matches on exact id instead of falling back to fragile text matching. The
|
|
54
|
+
// ack is asynchronous, so this is bounded + best-effort (retry a few times,
|
|
55
|
+
// then give up and keep the text fallback as the safety net). Tunable so a
|
|
56
|
+
// latency-sensitive deployment can shrink or disable it (RETRIES=0).
|
|
57
|
+
// Clamp to a non-negative integer, falling back to the default on a malformed
|
|
58
|
+
// value. Without this a typo (e.g. RETRIES="four" → NaN) would silently pass
|
|
59
|
+
// the `<= 0` / loop guards and DISABLE resolution — degrading the echo-guard to
|
|
60
|
+
// text-only with no error. `0` stays a valid, intentional disable.
|
|
61
|
+
function envIntNonNeg(name, def) {
|
|
62
|
+
const n = parseInt(process.env[name] || '', 10);
|
|
63
|
+
return Number.isFinite(n) && n >= 0 ? n : def;
|
|
64
|
+
}
|
|
65
|
+
const RESOLVE_RETRIES = envIntNonNeg('BEEPERBOX_RESOLVE_RETRIES', 4);
|
|
66
|
+
const RESOLVE_DELAY_MS = envIntNonNeg('BEEPERBOX_RESOLVE_DELAY_MS', 250);
|
|
67
|
+
// Per-attempt fetch timeout so a hung Beeper API can't stall a send unbounded:
|
|
68
|
+
// worst-case added send latency is RETRIES × (TIMEOUT + DELAY), not infinite.
|
|
69
|
+
const RESOLVE_TIMEOUT_MS = envIntNonNeg('BEEPERBOX_RESOLVE_TIMEOUT_MS', 3000);
|
|
70
|
+
|
|
71
|
+
// download_asset ships the bytes base64-encoded inside the JSON-RPC result, so
|
|
72
|
+
// a large attachment would bloat the response (base64 inflates ~33%) and the
|
|
73
|
+
// in-memory buffer. Cap it; tunable for a deployment that needs bigger files.
|
|
74
|
+
// FAQ/doc attachments — the driving use case — are well under this.
|
|
75
|
+
const MAX_ASSET_BYTES = envIntNonNeg('BEEPERBOX_MAX_ASSET_BYTES', 8 * 1024 * 1024);
|
|
76
|
+
// Per-call timeout on the asset-serve fetch so a hung or slow-drip source can't
|
|
77
|
+
// stall the request forever and the buffer can't grow unbounded behind it.
|
|
78
|
+
const ASSET_TIMEOUT_MS = envIntNonNeg('BEEPERBOX_ASSET_TIMEOUT_MS', 30000);
|
|
79
|
+
// Startup preflight: one bounded probe of the Beeper API on boot so a bad
|
|
80
|
+
// BEEPER_API / token is obvious in the logs immediately, not at first tool call.
|
|
81
|
+
// Container has a Docker HEALTHCHECK + supervises beepertexts; lite mode has
|
|
82
|
+
// neither, so this is its boot sanity check. Opt out with BEEPERBOX_PREFLIGHT=0.
|
|
83
|
+
const PREFLIGHT_TIMEOUT_MS = envIntNonNeg('BEEPERBOX_PREFLIGHT_TIMEOUT_MS', 5000);
|
|
84
|
+
|
|
85
|
+
// Real attachment src_urls come in two shapes: remote Matrix content
|
|
86
|
+
// (mxc:// / localmxc://) and, once Beeper caches the file locally,
|
|
87
|
+
// file:///root/.config/BeeperTexts/media/... — verified against a live account.
|
|
88
|
+
// Beeper's own /v1/assets/serve already guards this (it 403s a file:// outside
|
|
89
|
+
// its media dir and 400s a non-mxc/localmxc/file scheme — observed live). We do
|
|
90
|
+
// NOT lean on that undocumented upstream behavior: download_asset is the
|
|
91
|
+
// network-reachable MCP surface, so it independently allows mxc:// / localmxc://,
|
|
92
|
+
// allows file:// ONLY when it resolves inside the media cache, and refuses
|
|
93
|
+
// everything else BEFORE the fetch — defense in depth, plus a clear error
|
|
94
|
+
// instead of a raw Beeper 4xx. The file:// path is decoded + normalized and any
|
|
95
|
+
// authority/host or double-encoded dot/slash refused, so a caller can't smuggle
|
|
96
|
+
// ../ or a UNC host past the prefix check. Applied to BOTH the caller-supplied
|
|
97
|
+
// src_url and a src_url resolved off a message (a hostile sender could craft a
|
|
98
|
+
// file:// attachment pointing elsewhere).
|
|
99
|
+
const ASSET_FILE_ROOT = (() => {
|
|
100
|
+
const r = process.env.BEEPERBOX_ASSET_FILE_ROOT || '/root/.config/BeeperTexts/media/';
|
|
101
|
+
return r.endsWith('/') ? r : r + '/';
|
|
102
|
+
})();
|
|
103
|
+
|
|
104
|
+
function fileUrlInsideMediaCache(srcURL) {
|
|
105
|
+
let u;
|
|
106
|
+
try { u = new URL(srcURL); } catch { return false; }
|
|
107
|
+
// A file:// authority/host (file://host/path) is a network/UNC semantic the
|
|
108
|
+
// pathname check would miss — and we forward the ORIGINAL url, host and all.
|
|
109
|
+
// Legit local attachments have no host (file:///… ; even file://localhost
|
|
110
|
+
// normalizes the host away), so refuse any non-empty host.
|
|
111
|
+
if (u.host) return false;
|
|
112
|
+
let p;
|
|
113
|
+
try { p = decodeURIComponent(u.pathname); } catch { return false; }
|
|
114
|
+
// One decode matches Beeper serve's own single-decode (observed: it 404s a
|
|
115
|
+
// double-encoded path as a literal, never re-decodes). Any %2e/%2f still
|
|
116
|
+
// present after that decode is a DOUBLE-encoded dot/slash — an attempt to
|
|
117
|
+
// smuggle ../ past normalize() — so refuse rather than forward it. (A plain
|
|
118
|
+
// literal %25 in a real filename decodes to a bare % here, not %2e/%2f, so
|
|
119
|
+
// this doesn't over-reject normal names.)
|
|
120
|
+
if (/%2[ef]/i.test(p)) return false;
|
|
121
|
+
return path.posix.normalize(p).startsWith(ASSET_FILE_ROOT);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function assertServableSrcUrl(srcURL) {
|
|
125
|
+
const s = String(srcURL);
|
|
126
|
+
if (/^(?:mxc|localmxc):\/\//i.test(s)) return;
|
|
127
|
+
if (/^file:\/\//i.test(s)) {
|
|
128
|
+
if (fileUrlInsideMediaCache(s)) return;
|
|
129
|
+
throw rpcError(-32602, `file:// src_url must resolve inside the Beeper media cache (${ASSET_FILE_ROOT}); other paths are refused`);
|
|
130
|
+
}
|
|
131
|
+
throw rpcError(-32602, 'src_url must be an mxc://, localmxc://, or Beeper-cache file:// URL — other schemes are refused');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Strip the port from a Host header value ("127.0.0.1:23375" -> "127.0.0.1",
|
|
135
|
+
// "[::1]:23375" -> "[::1]").
|
|
136
|
+
function hostFromHeader(value) {
|
|
137
|
+
if (!value) return '';
|
|
138
|
+
const h = value.trim().toLowerCase();
|
|
139
|
+
if (h.startsWith('[')) return h.slice(0, h.indexOf(']') + 1) || h;
|
|
140
|
+
const c = h.indexOf(':');
|
|
141
|
+
return c >= 0 ? h.slice(0, c) : h;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Returns null when the request may proceed, else { status, message }.
|
|
145
|
+
function httpGuard(req) {
|
|
146
|
+
// DNS-rebinding guard: the browser sends the attacker's domain as Host.
|
|
147
|
+
const host = hostFromHeader(req.headers.host);
|
|
148
|
+
if (host && !MCP_ALLOWED_HOSTS.has(host)) {
|
|
149
|
+
return { status: 403, message: `host not allowed: ${host}` };
|
|
150
|
+
}
|
|
151
|
+
// Cross-origin browser guard: native clients (curl, MCP runtimes) send no
|
|
152
|
+
// Origin, so this only ever rejects browser-initiated cross-site requests.
|
|
153
|
+
const origin = req.headers.origin;
|
|
154
|
+
if (origin) {
|
|
155
|
+
let oh;
|
|
156
|
+
try { oh = new URL(origin).hostname.toLowerCase(); } catch { oh = null; }
|
|
157
|
+
if (!oh || !(MCP_ALLOWED_HOSTS.has(oh) || MCP_ALLOWED_HOSTS.has(`[${oh}]`))) {
|
|
158
|
+
return { status: 403, message: `origin not allowed: ${origin}` };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Bearer-token guard: only enforced when a token is configured.
|
|
162
|
+
if (MCP_AUTH_TOKEN && req.headers.authorization !== `Bearer ${MCP_AUTH_TOKEN}`) {
|
|
163
|
+
return { status: 401, message: 'unauthorized' };
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── beeper api helper ────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
async function beeperFetch(path, opts = {}) {
|
|
171
|
+
if (!BEEPER_TOKEN) throw rpcError(-32000, 'BEEPER_TOKEN env var not set — create a token in Beeper Settings > Developers and set the BEEPER_TOKEN environment variable');
|
|
172
|
+
const init = {
|
|
173
|
+
method: opts.method || 'GET',
|
|
174
|
+
headers: { Authorization: `Bearer ${BEEPER_TOKEN}` },
|
|
175
|
+
};
|
|
176
|
+
if (opts.body !== undefined) {
|
|
177
|
+
init.headers['Content-Type'] = 'application/json';
|
|
178
|
+
init.body = JSON.stringify(opts.body);
|
|
179
|
+
}
|
|
180
|
+
// Opt-in per-call timeout (opts.timeoutMs). Default callers are unbounded as
|
|
181
|
+
// before; the resolve path passes one so a hung API can't stall a send.
|
|
182
|
+
let timer = null;
|
|
183
|
+
if (opts.timeoutMs) {
|
|
184
|
+
const ac = new AbortController();
|
|
185
|
+
init.signal = ac.signal;
|
|
186
|
+
timer = setTimeout(() => ac.abort(), opts.timeoutMs);
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const r = await fetch(`${BEEPER_API}${path}`, init);
|
|
190
|
+
if (!r.ok) throw rpcError(-32001, `beeper api ${r.status}: ${(await r.text()).slice(0, 200)}`);
|
|
191
|
+
// Binary mode (opts.raw): return the bytes + content-type instead of
|
|
192
|
+
// parsing JSON — used by download_asset to serve attachment bytes. Enforce
|
|
193
|
+
// the byte cap twice: the Content-Length header (reject before buffering a
|
|
194
|
+
// well-behaved large response) and the actual buffered length (in case the
|
|
195
|
+
// header is absent or lies). Decoding these bytes as text would corrupt
|
|
196
|
+
// them, so this path never touches r.text()/JSON.parse.
|
|
197
|
+
if (opts.raw) {
|
|
198
|
+
const max = opts.maxBytes || 0;
|
|
199
|
+
const declared = parseInt(r.headers.get('content-length') || '', 10);
|
|
200
|
+
if (max && Number.isFinite(declared) && declared > max) {
|
|
201
|
+
throw rpcError(-32005, `asset is ${declared} bytes, over the ${max}-byte cap — raise BEEPERBOX_MAX_ASSET_BYTES or fetch /v1/assets/serve directly`);
|
|
202
|
+
}
|
|
203
|
+
const buf = Buffer.from(await r.arrayBuffer());
|
|
204
|
+
if (max && buf.length > max) {
|
|
205
|
+
throw rpcError(-32005, `asset is ${buf.length} bytes, over the ${max}-byte cap — raise BEEPERBOX_MAX_ASSET_BYTES or fetch /v1/assets/serve directly`);
|
|
206
|
+
}
|
|
207
|
+
return { bytes: buf, content_type: r.headers.get('content-type') || '' };
|
|
208
|
+
}
|
|
209
|
+
// Some POST/DELETE endpoints return empty body — return null instead of throwing on r.json()
|
|
210
|
+
const text = await r.text();
|
|
211
|
+
if (!text) return null;
|
|
212
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
213
|
+
} finally {
|
|
214
|
+
if (timer) clearTimeout(timer);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── network normalization ────────────────────────────────────────
|
|
219
|
+
// Beeper's /v1/accounts endpoint already returns a human-readable
|
|
220
|
+
// network name per account ("Discord", "WhatsApp", "Beeper (Matrix)").
|
|
221
|
+
// We cache the accountID -> {network, network_label} map at first use
|
|
222
|
+
// and look up each chat by its accountID. Chat objects do NOT encode
|
|
223
|
+
// the network in the room ID — that's an accountID lookup.
|
|
224
|
+
|
|
225
|
+
const NETWORK_SLUGS = {
|
|
226
|
+
'WhatsApp': 'whatsapp',
|
|
227
|
+
'iMessage': 'imessage',
|
|
228
|
+
'Telegram': 'telegram',
|
|
229
|
+
'Signal': 'signal',
|
|
230
|
+
'Discord': 'discord',
|
|
231
|
+
'Slack': 'slack',
|
|
232
|
+
'Instagram': 'instagram',
|
|
233
|
+
'Facebook Messenger': 'facebook',
|
|
234
|
+
'LinkedIn': 'linkedin',
|
|
235
|
+
'Google Messages': 'gmessages',
|
|
236
|
+
'X (Twitter)': 'twitter',
|
|
237
|
+
'Beeper (Matrix)': 'matrix',
|
|
238
|
+
'Matrix': 'matrix',
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
function networkSlug(label) {
|
|
242
|
+
return NETWORK_SLUGS[label] || String(label || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let accountCache = null;
|
|
246
|
+
let noteToSelfChatID = null;
|
|
247
|
+
|
|
248
|
+
async function getNoteToSelfChatID() {
|
|
249
|
+
if (noteToSelfChatID) return noteToSelfChatID;
|
|
250
|
+
// The "single participant who is self" heuristic also matches each platform's
|
|
251
|
+
// saved-messages chat (Telegram "Saved Messages", WhatsApp "Send to yourself",
|
|
252
|
+
// etc.). To avoid leaking agent self-notes onto a third-party network, require
|
|
253
|
+
// the chat live on the Beeper-native matrix account. Fall back to any single-
|
|
254
|
+
// self chat only if no matrix one exists (preserves prior behavior for users
|
|
255
|
+
// without a Beeper-native account, with a clearer error if nothing matches).
|
|
256
|
+
const [accounts, raw] = await Promise.all([
|
|
257
|
+
getAccountMap(),
|
|
258
|
+
beeperFetch('/v1/chats?limit=100'),
|
|
259
|
+
]);
|
|
260
|
+
const list = raw.items || raw.chats || (Array.isArray(raw) ? raw : []);
|
|
261
|
+
let fallback = null;
|
|
262
|
+
for (const c of list) {
|
|
263
|
+
const participants = c.participants?.items || [];
|
|
264
|
+
if (c.participants?.total !== 1 || participants[0]?.isSelf !== true) continue;
|
|
265
|
+
if (accounts[c.accountID]?.network === 'matrix') {
|
|
266
|
+
noteToSelfChatID = c.id;
|
|
267
|
+
return noteToSelfChatID;
|
|
268
|
+
}
|
|
269
|
+
if (!fallback) fallback = c.id;
|
|
270
|
+
}
|
|
271
|
+
if (fallback) {
|
|
272
|
+
// Defeats the matrix-preferred routing but is better than failing for users
|
|
273
|
+
// without a Beeper-native account. Log loudly so the behavior isn't silent
|
|
274
|
+
// — if a user expects matrix-native routing and sees this, something is
|
|
275
|
+
// misconfigured (matrix bridge offline, account just removed, etc.).
|
|
276
|
+
process.stderr.write(`[beeperbox-mcp] note_to_self: no Beeper-native matrix chat found; falling back to non-matrix single-self chat ${fallback} — agent self-notes will route to this platform's saved-messages chat\n`);
|
|
277
|
+
noteToSelfChatID = fallback;
|
|
278
|
+
return noteToSelfChatID;
|
|
279
|
+
}
|
|
280
|
+
throw rpcError(-32002, 'note-to-self chat not found in top 100 chats — open Beeper Desktop and verify a "Note to self" chat exists');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function getAccountMap() {
|
|
284
|
+
if (accountCache) return accountCache;
|
|
285
|
+
const accounts = await beeperFetch('/v1/accounts');
|
|
286
|
+
accountCache = {};
|
|
287
|
+
for (const a of (Array.isArray(accounts) ? accounts : (accounts.items || []))) {
|
|
288
|
+
accountCache[a.accountID] = {
|
|
289
|
+
network: networkSlug(a.network),
|
|
290
|
+
network_label: a.network,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
return accountCache;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── chat normalizer ──────────────────────────────────────────────
|
|
297
|
+
// Map Beeper's raw chat object into the schema MCP clients consume.
|
|
298
|
+
// One shape, returned everywhere — `list_inbox`, `list_unread`, `get_chat`.
|
|
299
|
+
//
|
|
300
|
+
// Real Beeper fields (verified against /v1/chats response):
|
|
301
|
+
// id → room ID (matrix-style)
|
|
302
|
+
// accountID → maps to /v1/accounts[].network
|
|
303
|
+
// title → chat title
|
|
304
|
+
// type → "group" | "single"
|
|
305
|
+
// participants → { items: [{isSelf}], total: N }
|
|
306
|
+
// lastActivity → ISO timestamp
|
|
307
|
+
// unreadCount → integer
|
|
308
|
+
|
|
309
|
+
// ─── message normalizer ───────────────────────────────────────────
|
|
310
|
+
// Map Beeper's raw message object into the second canonical shape
|
|
311
|
+
// MCP clients consume. Carries chat_id and network on every message
|
|
312
|
+
// so agents never need a second lookup for grounding.
|
|
313
|
+
//
|
|
314
|
+
// Real Beeper message fields (from /v1/chats/<id>/messages):
|
|
315
|
+
// id → message ID
|
|
316
|
+
// chatID → parent chat id
|
|
317
|
+
// senderID → sender Matrix-style ID
|
|
318
|
+
// senderName → human name
|
|
319
|
+
// isSender → true iff this user sent it
|
|
320
|
+
// timestamp → ISO 8601
|
|
321
|
+
// text → message body (when type === 'TEXT')
|
|
322
|
+
// type → 'TEXT' | 'MEDIA' | etc.
|
|
323
|
+
// replyTo → optional, parent message id
|
|
324
|
+
|
|
325
|
+
// Map Beeper's raw message `attachments[]` into a normalized shape MCP clients
|
|
326
|
+
// can act on. Pure passthrough of the fields that already ride the raw message
|
|
327
|
+
// — no extra fetch. `src_url` is the download reference (an mxc:// / localmxc://
|
|
328
|
+
// Matrix content URL, or a file:// URL into Beeper's media cache once the file
|
|
329
|
+
// is downloaded locally) that download_asset takes. `size` carries the byte
|
|
330
|
+
// length (raw `fileSize`) when present. Returns [] when
|
|
331
|
+
// there are no attachments, so the field is always an array.
|
|
332
|
+
function normalizeAttachments(raw) {
|
|
333
|
+
const list = Array.isArray(raw?.attachments) ? raw.attachments : [];
|
|
334
|
+
return list.map((a) => ({
|
|
335
|
+
type: a.type || null,
|
|
336
|
+
file_name: a.fileName || null,
|
|
337
|
+
mime_type: a.mimeType || null,
|
|
338
|
+
src_url: a.srcURL || null,
|
|
339
|
+
size: typeof a.fileSize === 'number' ? a.fileSize : null,
|
|
340
|
+
is_voice_note: !!a.isVoiceNote,
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function normalizeMessage(raw, chat) {
|
|
345
|
+
return {
|
|
346
|
+
id: String(raw.id),
|
|
347
|
+
chat_id: raw.chatID || chat?.id || null,
|
|
348
|
+
network: chat?.network || 'unknown',
|
|
349
|
+
network_label: chat?.network_label || 'Unknown',
|
|
350
|
+
sender: {
|
|
351
|
+
id: raw.senderID || null,
|
|
352
|
+
name: raw.senderName || null,
|
|
353
|
+
is_self: !!raw.isSender,
|
|
354
|
+
},
|
|
355
|
+
text: raw.text || (raw.type === 'TEXT' ? '' : `[${raw.type || 'non-text'}]`),
|
|
356
|
+
type: raw.type || 'TEXT',
|
|
357
|
+
// Media reach: a MEDIA message carries no usable `text`, so without this an
|
|
358
|
+
// agent could see "[MEDIA]" but never reach the file. Always an array.
|
|
359
|
+
attachments: normalizeAttachments(raw),
|
|
360
|
+
timestamp: raw.timestamp || null,
|
|
361
|
+
reply_to: raw.replyTo || raw.reply_to || null,
|
|
362
|
+
// Echo-guard origin. Defaults assume "not sent through this beeperbox";
|
|
363
|
+
// applyEchoTags() upgrades to source:'api' (+ client_tag) for messages
|
|
364
|
+
// the send_message / note_to_self tools recorded. See the sent-ledger
|
|
365
|
+
// section below for why is_self alone can't tell these apart.
|
|
366
|
+
source: 'external',
|
|
367
|
+
client_tag: null,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function normalizeChat(raw, accounts) {
|
|
372
|
+
const acct = accounts[raw.accountID] || { network: 'unknown', network_label: 'Unknown' };
|
|
373
|
+
const participants = raw.participants?.items || [];
|
|
374
|
+
// Note-to-self = chat with exactly one participant who is yourself.
|
|
375
|
+
// Catches Beeper's native Note-to-self AND each platform's saved-messages
|
|
376
|
+
// chat (Telegram "Saved Messages", WhatsApp "Send to yourself", etc.).
|
|
377
|
+
const isNoteToSelf = raw.participants?.total === 1 && participants[0]?.isSelf === true;
|
|
378
|
+
return {
|
|
379
|
+
id: raw.id,
|
|
380
|
+
title: raw.title || '(untitled)',
|
|
381
|
+
network: acct.network,
|
|
382
|
+
network_label: acct.network_label,
|
|
383
|
+
is_group: raw.type === 'group' && !isNoteToSelf,
|
|
384
|
+
is_note_to_self: isNoteToSelf,
|
|
385
|
+
last_message_at: raw.lastActivity || null,
|
|
386
|
+
unread_count: raw.unreadCount || 0,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ─── poll cursor (pure, stateless, restart-resumable) ─────────────
|
|
391
|
+
// The cursor is an opaque base64 of {ts, ids}: ts is the high-water
|
|
392
|
+
// message timestamp delivered so far, ids are the message ids seen at
|
|
393
|
+
// EXACTLY that ts. A message is "after" the cursor iff its ts is
|
|
394
|
+
// strictly greater, OR equal-ts but its id was not already in `ids`.
|
|
395
|
+
// The {ids} half is what makes same-millisecond messages dedup
|
|
396
|
+
// correctly instead of one silently swallowing the other — the exact
|
|
397
|
+
// seed/poll/dedup bug-class this primitive exists to retire. The caller
|
|
398
|
+
// persists the opaque string and passes it back; because no state lives
|
|
399
|
+
// server-side, resuming after a process/container restart is automatic.
|
|
400
|
+
|
|
401
|
+
function encodeCursor(state) {
|
|
402
|
+
return Buffer
|
|
403
|
+
.from(JSON.stringify({ ts: state.ts || '', ids: state.ids || [] }), 'utf8')
|
|
404
|
+
.toString('base64');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// A legitimate cursor only ever carries the ids sharing one high-water
|
|
408
|
+
// millisecond — at most a single poll page (≤100). Anything beyond this is a
|
|
409
|
+
// crafted/corrupt cursor; reject it rather than pay O(n) per message scanning
|
|
410
|
+
// `cur.ids.includes(...)` on attacker-chosen length.
|
|
411
|
+
const CURSOR_IDS_MAX = 4096;
|
|
412
|
+
|
|
413
|
+
function decodeCursor(cursor) {
|
|
414
|
+
if (!cursor) return null; // absent → seed mode (start from now)
|
|
415
|
+
let s;
|
|
416
|
+
try {
|
|
417
|
+
s = JSON.parse(Buffer.from(String(cursor), 'base64').toString('utf8'));
|
|
418
|
+
} catch {
|
|
419
|
+
s = undefined;
|
|
420
|
+
}
|
|
421
|
+
if (!s || typeof s.ts !== 'string' || !Array.isArray(s.ids)) {
|
|
422
|
+
throw rpcError(-32602, 'poll_messages: malformed cursor — pass back the exact cursor string from the previous response, or omit it to re-seed from now');
|
|
423
|
+
}
|
|
424
|
+
if (s.ids.length > CURSOR_IDS_MAX) {
|
|
425
|
+
throw rpcError(-32602, `poll_messages: cursor too large (${s.ids.length} ids) — it was not produced by this server; omit the cursor to re-seed`);
|
|
426
|
+
}
|
|
427
|
+
return { ts: s.ts, ids: s.ids.map(String) };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ISO-8601 timestamps (Beeper emits `...Z`) sort lexicographically, so a
|
|
431
|
+
// plain string compare is also a chronological compare.
|
|
432
|
+
function isAfterCursor(msg, cur) {
|
|
433
|
+
if (!cur) return true; // defensive; seed path returns early elsewhere
|
|
434
|
+
if (!msg.timestamp) return false; // unorderable → never enters the feed (can't advance past it)
|
|
435
|
+
if (msg.timestamp > cur.ts) return true;
|
|
436
|
+
if (msg.timestamp === cur.ts) return !cur.ids.includes(String(msg.id));
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Advance the cursor over the set we actually delivered (already filtered
|
|
441
|
+
// as after `prev`). New high-water ts resets the id set to that ts; more
|
|
442
|
+
// messages at the existing high-water ts accumulate into it.
|
|
443
|
+
function advanceCursor(prev, delivered) {
|
|
444
|
+
let ts = prev ? prev.ts : '';
|
|
445
|
+
let ids = prev ? prev.ids.slice() : [];
|
|
446
|
+
for (const m of delivered) {
|
|
447
|
+
if (!m.timestamp) continue;
|
|
448
|
+
if (m.timestamp > ts) { ts = m.timestamp; ids = [String(m.id)]; }
|
|
449
|
+
else if (m.timestamp === ts) { ids.push(String(m.id)); }
|
|
450
|
+
}
|
|
451
|
+
return { ts, ids: [...new Set(ids)] };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Pure: from the union of fresh (after-cursor) messages across all scanned
|
|
455
|
+
// chats, hand back the OLDEST `page` and advance the cursor over EXACTLY
|
|
456
|
+
// those. Delivering oldest-first (not newest) is what makes an over-`page`
|
|
457
|
+
// burst recoverable: the cursor moves forward only past what the caller
|
|
458
|
+
// actually received, so the newer remainder arrives on the next poll instead
|
|
459
|
+
// of being skipped. `hasMore` is true iff there is an immediately-fetchable
|
|
460
|
+
// remainder — i.e. re-polling now will return more.
|
|
461
|
+
function selectDelivery(fresh, cur, page) {
|
|
462
|
+
const sorted = fresh.slice().sort((a, b) =>
|
|
463
|
+
a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1
|
|
464
|
+
: a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
465
|
+
const hasMore = sorted.length > page;
|
|
466
|
+
const delivered = hasMore ? sorted.slice(0, page) : sorted;
|
|
467
|
+
return { delivered, next: advanceCursor(cur, delivered), hasMore };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ─── sent-message ledger (echo-guard) ─────────────────────────────
|
|
471
|
+
// Distinguishes "this message was sent THROUGH beeperbox's own send API"
|
|
472
|
+
// from "this message is from my Beeper account" (sender.is_self). On ONE
|
|
473
|
+
// account both the human owner's own typed messages AND the agent's API
|
|
474
|
+
// replies are is_self === true, so is_self cannot guard against an agent
|
|
475
|
+
// re-processing its own sends. We record every message send_message /
|
|
476
|
+
// note_to_self emits and tag it source:'api' (with any caller client_tag)
|
|
477
|
+
// on read-back; everything else stays source:'external'. The ledger is
|
|
478
|
+
// persisted to the config volume so the guard survives a restart — the
|
|
479
|
+
// brittle text-prefix guard this replaces was restart-durable too.
|
|
480
|
+
//
|
|
481
|
+
// PRIMARY match is exact id. The send only knows the pendingMessageID, which
|
|
482
|
+
// Beeper swaps for a real bridge id on ack — so we resolve the final id right
|
|
483
|
+
// after sending (resolveSentId) and record BOTH against the entry. A read-back
|
|
484
|
+
// then matches by exact id whether or not the swap happened. The content
|
|
485
|
+
// fallback survives only as a last-ditch safety net, and ONLY for entries we
|
|
486
|
+
// could NOT resolve a final id for: it matches our own (is_self) messages, in
|
|
487
|
+
// the same chat, inside a short window. Once an entry IS resolved, exact id is
|
|
488
|
+
// authoritative for it and the text fallback is deliberately disabled — that is
|
|
489
|
+
// what stops a human re-typing identical text from being mis-tagged as ours.
|
|
490
|
+
//
|
|
491
|
+
// CAVEAT (unverifiable in CI — no live Beeper account; validate against a real
|
|
492
|
+
// account before relying on it): the resolution and content fallback both
|
|
493
|
+
// depend on Beeper's live id/ack behavior.
|
|
494
|
+
|
|
495
|
+
const LEDGER_MAX = 500;
|
|
496
|
+
const LEDGER_TEXT_WINDOW_MS = 15 * 60 * 1000;
|
|
497
|
+
// Bound a caller-supplied client_tag so it can't bloat the persisted ledger
|
|
498
|
+
// (the tag is an idempotency key — 256 chars is far more than any real one).
|
|
499
|
+
const CLIENT_TAG_MAX = 256;
|
|
500
|
+
|
|
501
|
+
// Where the echo-guard ledger persists. BEEPERBOX_SENT_LEDGER overrides
|
|
502
|
+
// explicitly; otherwise a per-user XDG path under the config home. This is one
|
|
503
|
+
// code path for BOTH deployments — no container-detection — because os.homedir()
|
|
504
|
+
// is /root in the container (so the file lands on the persisted /root/.config
|
|
505
|
+
// volume) and the real user's home in lite mode (so a non-root host process can
|
|
506
|
+
// actually write it). The old hardcoded /root/.config/... default silently
|
|
507
|
+
// failed to persist on any normal host, degrading the echo-guard across
|
|
508
|
+
// restarts; this is the fix.
|
|
509
|
+
function ledgerPath() {
|
|
510
|
+
if (process.env.BEEPERBOX_SENT_LEDGER) return process.env.BEEPERBOX_SENT_LEDGER;
|
|
511
|
+
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
512
|
+
return path.join(configHome, 'beeperbox', 'sent-ledger.json');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let ledger = null; // lazily loaded array of { chat_id, sent_ids[], resolved, text_hash, client_tag, ts }
|
|
516
|
+
|
|
517
|
+
function textHash(text) {
|
|
518
|
+
return crypto.createHash('sha256')
|
|
519
|
+
.update(String(text == null ? '' : text).trim())
|
|
520
|
+
.digest('hex')
|
|
521
|
+
.slice(0, 16);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function loadLedger() {
|
|
525
|
+
if (ledger) return ledger;
|
|
526
|
+
ledger = [];
|
|
527
|
+
try {
|
|
528
|
+
const fs = require('fs');
|
|
529
|
+
const arr = JSON.parse(fs.readFileSync(ledgerPath(), 'utf8'));
|
|
530
|
+
if (Array.isArray(arr)) ledger = arr.slice(-LEDGER_MAX);
|
|
531
|
+
} catch { /* best-effort: no ledger yet, unreadable, or bad json → start empty */ }
|
|
532
|
+
return ledger;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let ledgerPersistWarned = false;
|
|
536
|
+
function persistLedger() {
|
|
537
|
+
try {
|
|
538
|
+
const fs = require('fs');
|
|
539
|
+
const p = ledgerPath();
|
|
540
|
+
// The per-user XDG dir (~/.config/beeperbox) won't exist on a fresh host —
|
|
541
|
+
// create it (recursive, ignores already-exists) so the very first send can
|
|
542
|
+
// persist instead of degrading the guard to in-memory.
|
|
543
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
544
|
+
fs.writeFileSync(p, JSON.stringify(ledger.slice(-LEDGER_MAX)), { mode: 0o600 });
|
|
545
|
+
} catch (e) {
|
|
546
|
+
// Swallow — a degraded echo-guard must never take down a send. Warn once.
|
|
547
|
+
if (!ledgerPersistWarned) {
|
|
548
|
+
ledgerPersistWarned = true;
|
|
549
|
+
process.stderr.write(`[beeperbox-mcp] sent-ledger persist failed (${e.code || e.message}) — echo-guard degrades to in-memory for this run\n`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function recordSent({ chat_id, sent_id, text, client_tag }) {
|
|
555
|
+
loadLedger();
|
|
556
|
+
const entry = {
|
|
557
|
+
chat_id,
|
|
558
|
+
// Carries every id this send is known by — the pendingMessageID now, the
|
|
559
|
+
// resolved bridge id once addResolvedId() lands it. `resolved` flips true
|
|
560
|
+
// when we have the authoritative final id (retires the text fallback).
|
|
561
|
+
sent_ids: sent_id ? [String(sent_id)] : [],
|
|
562
|
+
resolved: false,
|
|
563
|
+
text_hash: textHash(text),
|
|
564
|
+
client_tag: client_tag ? String(client_tag).slice(0, CLIENT_TAG_MAX) : null,
|
|
565
|
+
ts: Date.now(),
|
|
566
|
+
};
|
|
567
|
+
ledger.push(entry);
|
|
568
|
+
if (ledger.length > LEDGER_MAX) ledger = ledger.slice(-LEDGER_MAX);
|
|
569
|
+
persistLedger();
|
|
570
|
+
return entry; // so the caller can attach the resolved id (addResolvedId)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Attach the resolved final bridge id to a ledger entry and mark it exact-id
|
|
574
|
+
// authoritative, then persist so the resolution survives a restart. Idempotent.
|
|
575
|
+
function addResolvedId(entry, finalID) {
|
|
576
|
+
if (!entry || !finalID) return;
|
|
577
|
+
const s = String(finalID);
|
|
578
|
+
if (!entry.sent_ids) entry.sent_ids = [];
|
|
579
|
+
if (!entry.sent_ids.includes(s)) entry.sent_ids.push(s);
|
|
580
|
+
entry.resolved = true;
|
|
581
|
+
persistLedger();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Best-effort resolve a pendingMessageID to the final bridge id Beeper assigns
|
|
585
|
+
// on ack. The ack is asynchronous, so GET the message back a few times until
|
|
586
|
+
// its id differs from the pending one. Returns the final id, or null if it
|
|
587
|
+
// never swapped within the retry budget (caller keeps the pending id + text
|
|
588
|
+
// fallback). Never throws — a degraded echo-guard must never fail a send.
|
|
589
|
+
async function resolveSentId(chatID, pendingMessageID) {
|
|
590
|
+
const pend = pendingMessageID ? String(pendingMessageID) : '';
|
|
591
|
+
if (!pend || !chatID || RESOLVE_RETRIES <= 0) return null;
|
|
592
|
+
for (let attempt = 0; attempt < RESOLVE_RETRIES; attempt++) {
|
|
593
|
+
try {
|
|
594
|
+
const m = await beeperFetch(
|
|
595
|
+
`/v1/chats/${encodeURIComponent(chatID)}/messages/${encodeURIComponent(pend)}`,
|
|
596
|
+
{ timeoutMs: RESOLVE_TIMEOUT_MS },
|
|
597
|
+
);
|
|
598
|
+
// GET is by id → a single message object. Only trust that shape: a
|
|
599
|
+
// list/other response would let us read an arbitrary message's id and
|
|
600
|
+
// store the WRONG final id, which would mis-tag an unrelated read-back.
|
|
601
|
+
const id = m && m.id != null ? String(m.id) : null;
|
|
602
|
+
if (id && id !== pend) return id; // swapped to the real bridge id
|
|
603
|
+
} catch { /* not acked yet, timed out, or transient — retry */ }
|
|
604
|
+
if (attempt < RESOLVE_RETRIES - 1 && RESOLVE_DELAY_MS > 0) {
|
|
605
|
+
await new Promise((r) => setTimeout(r, RESOLVE_DELAY_MS));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Pure: decide api-vs-external origin + echoed client_tag for one message
|
|
612
|
+
// given the ledger entries and a `now`. Exact id match first (high
|
|
613
|
+
// confidence); content fallback only for our own recent same-chat sends.
|
|
614
|
+
function matchSentMessage(msg, entries, now) {
|
|
615
|
+
const mid = String(msg.id);
|
|
616
|
+
// 1. Exact id match — high confidence. Checks every id the send is known by:
|
|
617
|
+
// the pendingMessageID and (once resolved) the final bridge id, so a
|
|
618
|
+
// read-back matches whether or not Beeper swapped the id on ack. Tolerates
|
|
619
|
+
// the legacy single-`sent_id` entry shape for ledgers written pre-upgrade.
|
|
620
|
+
for (const e of entries) {
|
|
621
|
+
if (e.chat_id !== msg.chat_id) continue;
|
|
622
|
+
const ids = e.sent_ids || (e.sent_id ? [e.sent_id] : []);
|
|
623
|
+
for (const id of ids) {
|
|
624
|
+
if (id && String(id) === mid) return { source: 'api', client_tag: e.client_tag || null };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// 2. Last-ditch text fallback — ONLY for our own (is_self) messages, same
|
|
628
|
+
// chat, recent window, AND only against entries whose final id we could
|
|
629
|
+
// NOT resolve (resolved !== true). Once an entry is resolved, step 1 is
|
|
630
|
+
// authoritative for it; skipping its text match is what keeps a human
|
|
631
|
+
// re-typing identical text from being mis-tagged as ours and dropped.
|
|
632
|
+
if (msg.is_self) {
|
|
633
|
+
const h = textHash(msg.text);
|
|
634
|
+
for (const e of entries) {
|
|
635
|
+
if (e.chat_id !== msg.chat_id) continue;
|
|
636
|
+
if (e.resolved === true) continue;
|
|
637
|
+
if (e.text_hash === h && (now - e.ts) <= LEDGER_TEXT_WINDOW_MS) {
|
|
638
|
+
return { source: 'api', client_tag: e.client_tag || null };
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return { source: 'external', client_tag: null };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Stamp source/client_tag onto already-normalized messages.
|
|
646
|
+
function applyEchoTags(messages, now) {
|
|
647
|
+
const entries = loadLedger();
|
|
648
|
+
return messages.map((m) => {
|
|
649
|
+
const meta = matchSentMessage(
|
|
650
|
+
{ id: m.id, chat_id: m.chat_id, text: m.text, is_self: m.sender.is_self },
|
|
651
|
+
entries,
|
|
652
|
+
now,
|
|
653
|
+
);
|
|
654
|
+
return { ...m, source: meta.source, client_tag: meta.client_tag };
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ─── tool registry ────────────────────────────────────────────────
|
|
659
|
+
|
|
660
|
+
const TOOLS = [
|
|
661
|
+
{
|
|
662
|
+
name: 'list_accounts',
|
|
663
|
+
description: 'List all messaging accounts (networks) connected to this Beeper account. Each account corresponds to one platform — WhatsApp, Telegram, Discord, etc. Use this to see which platforms are reachable before calling other tools, or to discover what kinds of chats exist. Returns network slug (machine-readable, e.g. "whatsapp"), network label (human, e.g. "WhatsApp"), the underlying account ID, and the user\'s display name on that platform.',
|
|
664
|
+
inputSchema: {
|
|
665
|
+
type: 'object',
|
|
666
|
+
properties: {},
|
|
667
|
+
additionalProperties: false,
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
name: 'get_chat',
|
|
672
|
+
description: 'Fetch metadata for one specific chat by ID. Returns the same Chat schema as list_inbox so the caller does not need to learn a second shape. Use this when you have a chat ID from a previous call (e.g. from list_inbox or search_messages) and need its current state — most often to check unread_count, last_message_at, or title before replying.',
|
|
673
|
+
inputSchema: {
|
|
674
|
+
type: 'object',
|
|
675
|
+
properties: {
|
|
676
|
+
chat_id: { type: 'string', description: 'The chat ID to fetch (the `id` field from any Chat object returned by list_inbox or get_chat).' },
|
|
677
|
+
},
|
|
678
|
+
required: ['chat_id'],
|
|
679
|
+
additionalProperties: false,
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
name: 'read_chat',
|
|
684
|
+
description: 'Fetch the most recent messages from one chat. Returns messages in chronological order (oldest to newest within the page) with normalized sender info, network, and chat_id propagated to every message. Use this to read context before replying, or to pull the last few messages of a conversation for the LLM to reason about.',
|
|
685
|
+
inputSchema: {
|
|
686
|
+
type: 'object',
|
|
687
|
+
properties: {
|
|
688
|
+
chat_id: { type: 'string', description: 'The chat ID to read from (the `id` field from any Chat object).' },
|
|
689
|
+
limit: { type: 'integer', description: 'Max messages to return (default 20)', default: 20, minimum: 1, maximum: 100 },
|
|
690
|
+
},
|
|
691
|
+
required: ['chat_id'],
|
|
692
|
+
additionalProperties: false,
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
name: 'archive_chat',
|
|
697
|
+
description: 'Archive or unarchive a chat. Archived chats are moved out of the active inbox (list_inbox no longer returns them) but messages and history are preserved. Use this to clean up handled chats after replying or processing them. Beeper does not expose a mark-as-read endpoint, so archiving is the closest primitive for the "I am done with this conversation" pattern. Pass archived=false to unarchive.',
|
|
698
|
+
inputSchema: {
|
|
699
|
+
type: 'object',
|
|
700
|
+
properties: {
|
|
701
|
+
chat_id: { type: 'string', description: 'The chat ID to archive (the `id` field from any Chat object).' },
|
|
702
|
+
archived: { type: 'boolean', description: 'true to archive (default), false to unarchive', default: true },
|
|
703
|
+
},
|
|
704
|
+
required: ['chat_id'],
|
|
705
|
+
additionalProperties: false,
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
name: 'list_inbox',
|
|
710
|
+
description: 'List the most recently active chats from the user\'s connected messaging accounts. Excludes the bot\'s own note-to-self chat (use note_to_self for that). Returns chat metadata including network (whatsapp/telegram/imessage/etc.), title, unread count, and last activity timestamp.',
|
|
711
|
+
inputSchema: {
|
|
712
|
+
type: 'object',
|
|
713
|
+
properties: {
|
|
714
|
+
limit: { type: 'integer', description: 'Max chats to return (default 20)', default: 20, minimum: 1, maximum: 100 },
|
|
715
|
+
},
|
|
716
|
+
additionalProperties: false,
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
name: 'search_messages',
|
|
721
|
+
description: 'Full-text search across all messages in all chats. Returns matching messages with chat_id and network propagated so the LLM can immediately tell which conversation each hit belongs to without a second lookup. Use this for follow-up questions ("what did Sara say about the contract?"), historical lookups, or finding old context the agent does not have in its current window. Older history may not be indexed if Beeper has not synced it — see the "top 20 active chats" caveat in the GUIDE.',
|
|
722
|
+
inputSchema: {
|
|
723
|
+
type: 'object',
|
|
724
|
+
properties: {
|
|
725
|
+
query: { type: 'string', description: 'The text to search for. Plain text — no special operators.', minLength: 1 },
|
|
726
|
+
limit: { type: 'integer', description: 'Max messages to return (default 20)', default: 20, minimum: 1, maximum: 100 },
|
|
727
|
+
},
|
|
728
|
+
required: ['query'],
|
|
729
|
+
additionalProperties: false,
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
name: 'note_to_self',
|
|
734
|
+
description: 'Send a message to the bot\'s own Note to self chat — the dedicated command/control channel for the agent itself. Use this for agent self-notes ("processed 5 customer messages"), debugging output, scheduled reminders to self, or anything you want recorded but NOT seen by anyone else. Auto-resolves the correct chat ID, so no chat_id parameter needed. The note-to-self chat is excluded from list_inbox / list_unread / search_messages, so messages here will not pollute customer inbox views.',
|
|
735
|
+
inputSchema: {
|
|
736
|
+
type: 'object',
|
|
737
|
+
properties: {
|
|
738
|
+
text: { type: 'string', description: 'The note text. Markdown supported.', minLength: 1 },
|
|
739
|
+
client_tag: { type: 'string', description: 'Optional idempotency/echo tag. Recorded against this send and echoed back on the message as `client_tag` when it reappears in poll_messages / read_chat (where it is also marked source:"api"), so an agent can recognize and skip its own programmatic sends.' },
|
|
740
|
+
},
|
|
741
|
+
required: ['text'],
|
|
742
|
+
additionalProperties: false,
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
name: 'send_message',
|
|
747
|
+
description: 'Send a text message to a chat. The headline write operation. Use this to reply to a customer, send a notification, or initiate a conversation. The chat must already exist (use a chat_id from list_inbox / list_unread / search_messages / get_chat). Markdown is supported in the text. To reply specifically to one message rather than just adding to the conversation, pass reply_to_message_id. Returns the new message ID for downstream operations like react_to_message.',
|
|
748
|
+
inputSchema: {
|
|
749
|
+
type: 'object',
|
|
750
|
+
properties: {
|
|
751
|
+
chat_id: { type: 'string', description: 'The chat ID to send to (the `id` field from any Chat object).' },
|
|
752
|
+
text: { type: 'string', description: 'The message body. Markdown supported.', minLength: 1 },
|
|
753
|
+
reply_to_message_id: { type: 'string', description: 'Optional. Pass a message_id to send this as a reply to that specific message instead of as a new conversation entry.' },
|
|
754
|
+
client_tag: { type: 'string', description: 'Optional idempotency/echo tag. Recorded against this send and echoed back on the message as `client_tag` when it reappears in poll_messages / read_chat (where it is also marked source:"api"), so an agent can recognize and skip its own programmatic sends.' },
|
|
755
|
+
},
|
|
756
|
+
required: ['chat_id', 'text'],
|
|
757
|
+
additionalProperties: false,
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
name: 'react_to_message',
|
|
762
|
+
description: 'Add an emoji reaction to a specific message. The lightest possible "I saw it" or "ack" signal — use this when you want to acknowledge a message without sending a full reply. Pass the unicode emoji directly (e.g. "👍", "❤️", "✅"). Reactions are visible to the message sender on every supported network (WhatsApp, iMessage, Telegram, Discord, Slack, Signal, etc.).',
|
|
763
|
+
inputSchema: {
|
|
764
|
+
type: 'object',
|
|
765
|
+
properties: {
|
|
766
|
+
chat_id: { type: 'string', description: 'The chat ID containing the message (the `chat_id` field from any Message object).' },
|
|
767
|
+
message_id: { type: 'string', description: 'The message ID to react to (the `id` field from any Message object).' },
|
|
768
|
+
emoji: { type: 'string', description: 'The unicode emoji to react with (e.g. "👍", "❤️", "✅").' },
|
|
769
|
+
},
|
|
770
|
+
required: ['chat_id', 'message_id', 'emoji'],
|
|
771
|
+
additionalProperties: false,
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
{
|
|
775
|
+
name: 'list_unread',
|
|
776
|
+
description: 'List chats that have one or more unread messages. Same Chat schema as list_inbox, filtered to only chats where unread_count > 0. Use this as the primary "what needs my attention right now?" tool — agents typically call this first to triage, then read_chat on each result to fetch the actual unread messages.',
|
|
777
|
+
inputSchema: {
|
|
778
|
+
type: 'object',
|
|
779
|
+
properties: {
|
|
780
|
+
limit: { type: 'integer', description: 'Max chats to return (default 20)', default: 20, minimum: 1, maximum: 100 },
|
|
781
|
+
},
|
|
782
|
+
additionalProperties: false,
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
name: 'poll_messages',
|
|
787
|
+
description: 'Passive "what is new since I last looked?" feed — the watch primitive for a poll loop. Returns every message that arrived after an opaque cursor, across all recent chats (or one chat if chat_id is given), each as the normalized Message schema. It is READ-ONLY: it never marks anything read, never archives, never mutates — call it as often as you like with zero side effects. First call (omit cursor) SEEDS: it returns {cursor, messages: [], seeded: true} — an empty backlog and a starting cursor meaning "from now". Persist that cursor; pass it back next call to receive only messages newer than it, plus a fresh cursor. The cursor is restart-safe: save it to disk and resume across process/container restarts with no missed or duplicated messages. Includes the account\'s own messages (sender.is_self may be true) on purpose — the owner messaging themselves in Note to self is a real inbound signal. To avoid an agent answering its own sends, branch on the `source` field, NOT is_self: source is "api" for messages this beeperbox sent via send_message / note_to_self (skip these), "external" for everything else (the human owner\'s own messages included). If has_more is true, more messages are waiting beyond this page — poll again immediately with the returned cursor before sleeping.',
|
|
788
|
+
inputSchema: {
|
|
789
|
+
type: 'object',
|
|
790
|
+
properties: {
|
|
791
|
+
cursor: { type: 'string', description: 'Opaque cursor from a previous poll_messages response. Omit on the first call to seed from now. Pass back verbatim — never construct or parse it.' },
|
|
792
|
+
chat_id: { type: 'string', description: 'Optional. Restrict the feed to one chat (the `id` from any Chat object). Omit to watch all recent chats.' },
|
|
793
|
+
limit: { type: 'integer', description: 'Max messages to return this call (default 50). If more are pending, has_more is true and they come on the next poll.', default: 50, minimum: 1, maximum: 100 },
|
|
794
|
+
},
|
|
795
|
+
additionalProperties: false,
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
name: 'download_asset',
|
|
800
|
+
description: 'Download the bytes of a message attachment (image, PDF, document, voice note, etc.) and return them base64-encoded. This is the MCP-only way to reach an attachment\'s actual content — use it after read_chat / poll_messages surfaces a message whose `attachments[]` carries a `src_url`. Reference the attachment EITHER by passing its `src_url` directly (the value from a normalized attachment — an mxc:// / localmxc:// URL, or a file:// URL inside Beeper\'s local media cache; arbitrary file:// paths and other schemes are refused), OR by `chat_id` + `message_id` (+ optional `index` to pick one of several attachments), in which case beeperbox resolves the src_url for you and also returns the attachment\'s file_name / mime_type / size. Returns { content_type, bytes, encoding: "base64", data_base64, ... }. Large files are capped (default 8 MiB) to keep the JSON-RPC result bounded — raise BEEPERBOX_MAX_ASSET_BYTES if you need bigger.',
|
|
801
|
+
inputSchema: {
|
|
802
|
+
type: 'object',
|
|
803
|
+
properties: {
|
|
804
|
+
src_url: { type: 'string', description: 'The attachment\'s `src_url` from a normalized Message attachment — an mxc:// / localmxc:// URL or a file:// URL inside Beeper\'s media cache. Arbitrary file:// paths and other schemes are refused. Provide this, OR chat_id + message_id.' },
|
|
805
|
+
chat_id: { type: 'string', description: 'The chat containing the message. Required if src_url is omitted (used with message_id to resolve the attachment).' },
|
|
806
|
+
message_id: { type: 'string', description: 'The message whose attachment to download. Required if src_url is omitted.' },
|
|
807
|
+
index: { type: 'integer', description: 'Which attachment to download when resolving by message_id and the message has more than one (default 0).', default: 0, minimum: 0 },
|
|
808
|
+
},
|
|
809
|
+
additionalProperties: false,
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
async function callTool(name, args) {
|
|
815
|
+
switch (name) {
|
|
816
|
+
case 'list_accounts': {
|
|
817
|
+
const accounts = await beeperFetch('/v1/accounts');
|
|
818
|
+
const list = Array.isArray(accounts) ? accounts : (accounts.items || []);
|
|
819
|
+
return list.map((a) => ({
|
|
820
|
+
account_id: a.accountID,
|
|
821
|
+
network: networkSlug(a.network),
|
|
822
|
+
network_label: a.network,
|
|
823
|
+
user: {
|
|
824
|
+
id: a.user?.id || null,
|
|
825
|
+
display_name: a.user?.fullName || a.user?.displayText || a.user?.username || null,
|
|
826
|
+
},
|
|
827
|
+
}));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
case 'get_chat': {
|
|
831
|
+
if (!args.chat_id) throw rpcError(-32602, 'get_chat requires chat_id');
|
|
832
|
+
const [accounts, raw] = await Promise.all([
|
|
833
|
+
getAccountMap(),
|
|
834
|
+
beeperFetch(`/v1/chats/${encodeURIComponent(args.chat_id)}`),
|
|
835
|
+
]);
|
|
836
|
+
return normalizeChat(raw, accounts);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
case 'read_chat': {
|
|
840
|
+
if (!args.chat_id) throw rpcError(-32602, 'read_chat requires chat_id');
|
|
841
|
+
const limit = Math.min(Math.max(args.limit || 20, 1), 100);
|
|
842
|
+
// Same Beeper-side minimum-page-size workaround as list_inbox: the API
|
|
843
|
+
// returns ~25 items regardless of ?limit=, so over-fetch then slice.
|
|
844
|
+
const [accounts, chatRaw, msgRaw] = await Promise.all([
|
|
845
|
+
getAccountMap(),
|
|
846
|
+
beeperFetch(`/v1/chats/${encodeURIComponent(args.chat_id)}`),
|
|
847
|
+
beeperFetch(`/v1/chats/${encodeURIComponent(args.chat_id)}/messages?limit=${Math.max(limit, 25)}`),
|
|
848
|
+
]);
|
|
849
|
+
const chat = normalizeChat(chatRaw, accounts);
|
|
850
|
+
const list = msgRaw.items || msgRaw.messages || (Array.isArray(msgRaw) ? msgRaw : []);
|
|
851
|
+
// Beeper returns newest first; reverse so oldest comes first within the page
|
|
852
|
+
// (more natural for an LLM building a conversation thread).
|
|
853
|
+
const msgs = list.slice(0, limit).map((m) => normalizeMessage(m, chat)).reverse();
|
|
854
|
+
return applyEchoTags(msgs, Date.now());
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
case 'archive_chat': {
|
|
858
|
+
if (!args.chat_id) throw rpcError(-32602, 'archive_chat requires chat_id');
|
|
859
|
+
const archived = args.archived !== false; // default true
|
|
860
|
+
await beeperFetch(`/v1/chats/${encodeURIComponent(args.chat_id)}/archive`, {
|
|
861
|
+
method: 'POST',
|
|
862
|
+
body: { archived },
|
|
863
|
+
});
|
|
864
|
+
return { chat_id: args.chat_id, archived };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
case 'list_inbox': {
|
|
868
|
+
const limit = Math.min(Math.max(args.limit || 20, 1), 100);
|
|
869
|
+
// Beeper returns ~25 items minimum regardless of ?limit=, so we fetch
|
|
870
|
+
// at least that many, then slice client-side after note-to-self filter.
|
|
871
|
+
const [accounts, raw] = await Promise.all([
|
|
872
|
+
getAccountMap(),
|
|
873
|
+
beeperFetch(`/v1/chats?limit=${Math.max(limit, 25)}`),
|
|
874
|
+
]);
|
|
875
|
+
const list = raw.items || raw.chats || (Array.isArray(raw) ? raw : []);
|
|
876
|
+
return list
|
|
877
|
+
.map((c) => normalizeChat(c, accounts))
|
|
878
|
+
.filter((c) => !c.is_note_to_self)
|
|
879
|
+
.slice(0, limit);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
case 'search_messages': {
|
|
883
|
+
if (!args.query) throw rpcError(-32602, 'search_messages requires query');
|
|
884
|
+
const limit = Math.min(Math.max(args.limit || 20, 1), 100);
|
|
885
|
+
const [accounts, raw] = await Promise.all([
|
|
886
|
+
getAccountMap(),
|
|
887
|
+
beeperFetch(`/v1/messages/search?query=${encodeURIComponent(args.query)}&limit=${limit}`),
|
|
888
|
+
]);
|
|
889
|
+
const items = raw?.items || [];
|
|
890
|
+
const chatsMap = raw?.chats || {};
|
|
891
|
+
// Pre-normalize the chat map so each hit can carry network info cheaply.
|
|
892
|
+
const normalizedChats = {};
|
|
893
|
+
for (const [id, c] of Object.entries(chatsMap)) {
|
|
894
|
+
normalizedChats[id] = normalizeChat(c, accounts);
|
|
895
|
+
}
|
|
896
|
+
const msgs = items.slice(0, limit).map((m) => {
|
|
897
|
+
const chat = normalizedChats[m.chatID] || { network: 'unknown', network_label: 'Unknown' };
|
|
898
|
+
return normalizeMessage(m, chat);
|
|
899
|
+
});
|
|
900
|
+
return applyEchoTags(msgs, Date.now());
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
case 'note_to_self': {
|
|
904
|
+
if (!args.text) throw rpcError(-32602, 'note_to_self requires text');
|
|
905
|
+
const chatID = await getNoteToSelfChatID();
|
|
906
|
+
const sent = await beeperFetch(
|
|
907
|
+
`/v1/chats/${encodeURIComponent(chatID)}/messages`,
|
|
908
|
+
{ method: 'POST', body: { text: args.text } },
|
|
909
|
+
);
|
|
910
|
+
const pendingID = String(sent?.pendingMessageID || '');
|
|
911
|
+
// Echo-guard: record so poll_messages / read_chat can tag this
|
|
912
|
+
// self-note source:'api' and the agent won't re-process its own note.
|
|
913
|
+
const entry = recordSent({ chat_id: chatID, sent_id: pendingID, text: args.text, client_tag: args.client_tag });
|
|
914
|
+
// Resolve the pending id to the final bridge id so the guard matches the
|
|
915
|
+
// read-back by exact id, not fragile text. Best-effort — null on timeout.
|
|
916
|
+
const finalID = await resolveSentId(chatID, pendingID);
|
|
917
|
+
if (finalID) addResolvedId(entry, finalID);
|
|
918
|
+
return {
|
|
919
|
+
chat_id: chatID,
|
|
920
|
+
message_id: finalID || pendingID,
|
|
921
|
+
pending_message_id: pendingID,
|
|
922
|
+
resolved: !!finalID,
|
|
923
|
+
client_tag: args.client_tag || null,
|
|
924
|
+
status: 'sent',
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
case 'send_message': {
|
|
929
|
+
if (!args.chat_id) throw rpcError(-32602, 'send_message requires chat_id');
|
|
930
|
+
if (!args.text) throw rpcError(-32602, 'send_message requires text');
|
|
931
|
+
const body = { text: args.text };
|
|
932
|
+
if (args.reply_to_message_id) body.replyToMessageID = args.reply_to_message_id;
|
|
933
|
+
const sent = await beeperFetch(
|
|
934
|
+
`/v1/chats/${encodeURIComponent(args.chat_id)}/messages`,
|
|
935
|
+
{ method: 'POST', body },
|
|
936
|
+
);
|
|
937
|
+
const chatID = sent?.chatID || args.chat_id;
|
|
938
|
+
const pendingID = String(sent?.pendingMessageID || '');
|
|
939
|
+
// Echo-guard: record so a poll_messages loop can skip the bot's own
|
|
940
|
+
// reply (source:'api') instead of treating it as a new inbound message.
|
|
941
|
+
const entry = recordSent({ chat_id: chatID, sent_id: pendingID, text: args.text, client_tag: args.client_tag });
|
|
942
|
+
// Resolve the pending id to the final bridge id so the guard matches the
|
|
943
|
+
// read-back by exact id, not fragile text. Best-effort — null on timeout.
|
|
944
|
+
const finalID = await resolveSentId(chatID, pendingID);
|
|
945
|
+
if (finalID) addResolvedId(entry, finalID);
|
|
946
|
+
return {
|
|
947
|
+
chat_id: chatID,
|
|
948
|
+
message_id: finalID || pendingID,
|
|
949
|
+
pending_message_id: pendingID,
|
|
950
|
+
resolved: !!finalID,
|
|
951
|
+
client_tag: args.client_tag || null,
|
|
952
|
+
status: 'sent',
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
case 'react_to_message': {
|
|
957
|
+
if (!args.chat_id) throw rpcError(-32602, 'react_to_message requires chat_id');
|
|
958
|
+
if (!args.message_id) throw rpcError(-32602, 'react_to_message requires message_id');
|
|
959
|
+
if (!args.emoji) throw rpcError(-32602, 'react_to_message requires emoji');
|
|
960
|
+
await beeperFetch(
|
|
961
|
+
`/v1/chats/${encodeURIComponent(args.chat_id)}/messages/${encodeURIComponent(args.message_id)}/reactions`,
|
|
962
|
+
{ method: 'POST', body: { reactionKey: args.emoji } },
|
|
963
|
+
);
|
|
964
|
+
return { chat_id: args.chat_id, message_id: args.message_id, emoji: args.emoji, status: 'reacted' };
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
case 'list_unread': {
|
|
968
|
+
const limit = Math.min(Math.max(args.limit || 20, 1), 100);
|
|
969
|
+
// Pull a wider page than the user's limit so the unread filter has
|
|
970
|
+
// headroom — most chats will be already-read so we need to over-fetch.
|
|
971
|
+
const [accounts, raw] = await Promise.all([
|
|
972
|
+
getAccountMap(),
|
|
973
|
+
beeperFetch(`/v1/chats?limit=100`),
|
|
974
|
+
]);
|
|
975
|
+
const list = raw.items || raw.chats || (Array.isArray(raw) ? raw : []);
|
|
976
|
+
return list
|
|
977
|
+
.map((c) => normalizeChat(c, accounts))
|
|
978
|
+
.filter((c) => !c.is_note_to_self && c.unread_count > 0)
|
|
979
|
+
.slice(0, limit);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
case 'poll_messages': {
|
|
983
|
+
const cur = decodeCursor(args.cursor); // null ⇒ seed mode
|
|
984
|
+
const page = Math.min(Math.max(args.limit || 50, 1), 100);
|
|
985
|
+
const accounts = await getAccountMap();
|
|
986
|
+
|
|
987
|
+
// Resolve which chats to scan: one if chat_id given, else the inbox.
|
|
988
|
+
let chats;
|
|
989
|
+
if (args.chat_id) {
|
|
990
|
+
const raw = await beeperFetch(`/v1/chats/${encodeURIComponent(args.chat_id)}`);
|
|
991
|
+
chats = [normalizeChat(raw, accounts)];
|
|
992
|
+
} else {
|
|
993
|
+
const raw = await beeperFetch('/v1/chats?limit=100');
|
|
994
|
+
const list = raw.items || raw.chats || (Array.isArray(raw) ? raw : []);
|
|
995
|
+
chats = list.map((c) => normalizeChat(c, accounts));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Seed (no cursor): return the current high-water mark and NO backlog,
|
|
999
|
+
// so the caller "starts watching from now". This is the half of the
|
|
1000
|
+
// bug-class that integrators get wrong — there's now one right answer.
|
|
1001
|
+
if (!cur) {
|
|
1002
|
+
let maxTs = '';
|
|
1003
|
+
for (const c of chats) {
|
|
1004
|
+
if (c.last_message_at && c.last_message_at > maxTs) maxTs = c.last_message_at;
|
|
1005
|
+
}
|
|
1006
|
+
return { cursor: encodeCursor({ ts: maxTs, ids: [] }), messages: [], has_more: false, seeded: true };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Only chats whose last activity is at/after the cursor can hold new
|
|
1010
|
+
// messages — skip the rest so the per-chat fan-out stays bounded.
|
|
1011
|
+
const candidates = args.chat_id
|
|
1012
|
+
? chats
|
|
1013
|
+
: chats.filter((c) => !c.last_message_at || c.last_message_at >= cur.ts);
|
|
1014
|
+
|
|
1015
|
+
// Fetch the Beeper max (100) per chat, NOT `page`: the fetch window must
|
|
1016
|
+
// exceed the delivery page so that a chat with more than `page` new
|
|
1017
|
+
// messages is delivered across successive polls via the cursor, rather
|
|
1018
|
+
// than the cursor jumping to the newest and stranding the older ones.
|
|
1019
|
+
// (Residual: a single chat receiving >100 messages between two polls can
|
|
1020
|
+
// still lose the oldest of that burst — Beeper's messages endpoint only
|
|
1021
|
+
// returns the newest N with no backward paging. Documented in the GUIDE.)
|
|
1022
|
+
const fetchLimit = 100;
|
|
1023
|
+
const fresh = [];
|
|
1024
|
+
for (const c of candidates) {
|
|
1025
|
+
const msgRaw = await beeperFetch(`/v1/chats/${encodeURIComponent(c.id)}/messages?limit=${fetchLimit}`);
|
|
1026
|
+
const mlist = msgRaw.items || msgRaw.messages || (Array.isArray(msgRaw) ? msgRaw : []);
|
|
1027
|
+
for (const m of mlist.map((x) => normalizeMessage(x, c))) {
|
|
1028
|
+
if (isAfterCursor(m, cur)) fresh.push(m);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const { delivered, next, hasMore } = selectDelivery(fresh, cur, page);
|
|
1033
|
+
const tagged = applyEchoTags(delivered, Date.now());
|
|
1034
|
+
return { cursor: encodeCursor(next), messages: tagged, has_more: hasMore };
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
case 'download_asset': {
|
|
1038
|
+
// Two ways to reference the attachment: a src_url directly (cheapest —
|
|
1039
|
+
// multis already has it from the normalized message), or chat_id +
|
|
1040
|
+
// message_id (+ index), where we fetch the message and read the src_url
|
|
1041
|
+
// off attachments[index] — which also lets us return its file metadata.
|
|
1042
|
+
let srcURL = args.src_url ? String(args.src_url) : '';
|
|
1043
|
+
let meta = {};
|
|
1044
|
+
if (!srcURL) {
|
|
1045
|
+
if (!args.chat_id || !args.message_id) {
|
|
1046
|
+
throw rpcError(-32602, 'download_asset requires src_url, or both chat_id and message_id');
|
|
1047
|
+
}
|
|
1048
|
+
const raw = await beeperFetch(
|
|
1049
|
+
`/v1/chats/${encodeURIComponent(args.chat_id)}/messages/${encodeURIComponent(args.message_id)}`,
|
|
1050
|
+
);
|
|
1051
|
+
const atts = normalizeAttachments(raw);
|
|
1052
|
+
const idx = Math.max(parseInt(args.index, 10) || 0, 0);
|
|
1053
|
+
const att = atts[idx];
|
|
1054
|
+
if (!att) throw rpcError(-32602, `message has no attachment at index ${idx} (found ${atts.length})`);
|
|
1055
|
+
if (!att.src_url) throw rpcError(-32004, `attachment ${idx} has no src_url to download`);
|
|
1056
|
+
srcURL = att.src_url;
|
|
1057
|
+
meta = { file_name: att.file_name, mime_type: att.mime_type, size: att.size };
|
|
1058
|
+
}
|
|
1059
|
+
// Confine the src_url to a real attachment (mxc/localmxc, or file:// inside
|
|
1060
|
+
// the media cache) BEFORE the fetch — defense-in-depth over Beeper serve's
|
|
1061
|
+
// own path guard, covering both ref paths. See assertServableSrcUrl.
|
|
1062
|
+
assertServableSrcUrl(srcURL);
|
|
1063
|
+
// /v1/assets/serve streams the bytes for an mxc:// / localmxc:// URL.
|
|
1064
|
+
// beeperFetch raw-mode returns the buffer + content-type, enforces the
|
|
1065
|
+
// byte cap (header pre-check + buffered post-check), and the timeout
|
|
1066
|
+
// bounds a hung/slow-drip source.
|
|
1067
|
+
const { bytes, content_type } = await beeperFetch(
|
|
1068
|
+
`/v1/assets/serve?url=${encodeURIComponent(srcURL)}`,
|
|
1069
|
+
{ raw: true, maxBytes: MAX_ASSET_BYTES, timeoutMs: ASSET_TIMEOUT_MS },
|
|
1070
|
+
);
|
|
1071
|
+
return {
|
|
1072
|
+
src_url: srcURL,
|
|
1073
|
+
...meta,
|
|
1074
|
+
content_type: content_type || meta.mime_type || null,
|
|
1075
|
+
bytes: bytes.length,
|
|
1076
|
+
encoding: 'base64',
|
|
1077
|
+
data_base64: bytes.toString('base64'),
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
default:
|
|
1081
|
+
throw rpcError(-32601, `unknown tool: ${name}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ─── jsonrpc dispatch ─────────────────────────────────────────────
|
|
1086
|
+
|
|
1087
|
+
function rpcError(code, message) {
|
|
1088
|
+
const e = new Error(message);
|
|
1089
|
+
e.rpcCode = code;
|
|
1090
|
+
return e;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async function handleRequest(req) {
|
|
1094
|
+
// Notifications carry no id; the spec says no response is sent.
|
|
1095
|
+
const isNotification = req.id === undefined;
|
|
1096
|
+
|
|
1097
|
+
if (req.jsonrpc !== '2.0') {
|
|
1098
|
+
if (isNotification) return null;
|
|
1099
|
+
return { jsonrpc: '2.0', id: req.id ?? null, error: { code: -32600, message: 'jsonrpc must be "2.0"' } };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
try {
|
|
1103
|
+
let result;
|
|
1104
|
+
switch (req.method) {
|
|
1105
|
+
case 'initialize':
|
|
1106
|
+
result = {
|
|
1107
|
+
protocolVersion: '2025-03-26',
|
|
1108
|
+
serverInfo: { name: 'beeperbox', version: VERSION },
|
|
1109
|
+
capabilities: { tools: {} },
|
|
1110
|
+
};
|
|
1111
|
+
break;
|
|
1112
|
+
|
|
1113
|
+
case 'notifications/initialized':
|
|
1114
|
+
// Client tells us it finished initializing. No response.
|
|
1115
|
+
return null;
|
|
1116
|
+
|
|
1117
|
+
case 'tools/list':
|
|
1118
|
+
result = { tools: TOOLS };
|
|
1119
|
+
break;
|
|
1120
|
+
|
|
1121
|
+
case 'tools/call': {
|
|
1122
|
+
const params = req.params || {};
|
|
1123
|
+
const name = params.name;
|
|
1124
|
+
const args = params.arguments || {};
|
|
1125
|
+
if (!name) throw rpcError(-32602, 'tools/call requires params.name');
|
|
1126
|
+
const data = await callTool(name, args);
|
|
1127
|
+
// MCP wraps tool results in a content array of typed parts.
|
|
1128
|
+
result = { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
1129
|
+
break;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
default:
|
|
1133
|
+
throw rpcError(-32601, `unknown method: ${req.method}`);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (isNotification) return null;
|
|
1137
|
+
return { jsonrpc: '2.0', id: req.id, result };
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
if (isNotification) return null;
|
|
1140
|
+
return {
|
|
1141
|
+
jsonrpc: '2.0',
|
|
1142
|
+
id: req.id ?? null,
|
|
1143
|
+
error: { code: err.rpcCode || -32603, message: err.message },
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// ─── startup preflight ────────────────────────────────────────────
|
|
1149
|
+
// Fire-and-forget boot probe. Calls /v1/accounts once (token-gated, so it
|
|
1150
|
+
// proves reachability AND that the token is accepted in a single hit) and logs
|
|
1151
|
+
// one clear verdict. Best-effort and non-fatal: a down API or unset token must
|
|
1152
|
+
// NOT stop the server from booting (the container's first run has no token /
|
|
1153
|
+
// no login yet, by design). Always logs to stderr — never the protocol channel
|
|
1154
|
+
// — so it's safe under both HTTP and stdio transports.
|
|
1155
|
+
async function preflight() {
|
|
1156
|
+
if (process.env.BEEPERBOX_PREFLIGHT === '0') return;
|
|
1157
|
+
const say = (m) => process.stderr.write(`[beeperbox-mcp] ${m}\n`);
|
|
1158
|
+
if (!BEEPER_TOKEN) {
|
|
1159
|
+
say('preflight: BEEPER_TOKEN not set — skipping reachability check (tool calls will fail until it is set)');
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
try {
|
|
1163
|
+
const accounts = await beeperFetch('/v1/accounts', { timeoutMs: PREFLIGHT_TIMEOUT_MS });
|
|
1164
|
+
const list = Array.isArray(accounts) ? accounts : (accounts?.items || []);
|
|
1165
|
+
say(`preflight OK: ${BEEPER_API} reachable, token accepted, ${list.length} account(s)`);
|
|
1166
|
+
} catch (e) {
|
|
1167
|
+
say(`preflight FAIL: ${BEEPER_API} unreachable or token rejected — ${e.message}`);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// ─── http transport ───────────────────────────────────────────────
|
|
1172
|
+
|
|
1173
|
+
function startHttpTransport() {
|
|
1174
|
+
const server = http.createServer((req, res) => {
|
|
1175
|
+
if (req.method !== 'POST') {
|
|
1176
|
+
res.writeHead(405, { Allow: 'POST' }).end();
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const denied = httpGuard(req);
|
|
1181
|
+
if (denied) {
|
|
1182
|
+
res.writeHead(denied.status, { 'Content-Type': 'application/json' });
|
|
1183
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: denied.message } }));
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
let body = '';
|
|
1188
|
+
let aborted = false;
|
|
1189
|
+
req.on('data', (chunk) => {
|
|
1190
|
+
if (aborted) return;
|
|
1191
|
+
body += chunk;
|
|
1192
|
+
if (body.length > MCP_MAX_BODY) {
|
|
1193
|
+
aborted = true;
|
|
1194
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
1195
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: `request body exceeds ${MCP_MAX_BODY} bytes` } }));
|
|
1196
|
+
req.destroy();
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
req.on('end', async () => {
|
|
1200
|
+
if (aborted) return;
|
|
1201
|
+
let parsed;
|
|
1202
|
+
try {
|
|
1203
|
+
parsed = JSON.parse(body);
|
|
1204
|
+
} catch {
|
|
1205
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1206
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }));
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const response = await handleRequest(parsed);
|
|
1211
|
+
if (response === null) {
|
|
1212
|
+
res.writeHead(204).end();
|
|
1213
|
+
} else {
|
|
1214
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1215
|
+
res.end(JSON.stringify(response));
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
server.listen(PORT, '0.0.0.0', () => {
|
|
1221
|
+
console.log(`[beeperbox-mcp] listening on http://0.0.0.0:${PORT}`);
|
|
1222
|
+
console.log(`[beeperbox-mcp] beeper api: ${BEEPER_API}`);
|
|
1223
|
+
console.log(`[beeperbox-mcp] beeper token: ${BEEPER_TOKEN ? 'set' : 'NOT SET (set BEEPER_TOKEN env var)'}`);
|
|
1224
|
+
console.log(`[beeperbox-mcp] http auth: ${MCP_AUTH_TOKEN ? 'required (MCP_AUTH_TOKEN set)' : 'OPEN — set MCP_AUTH_TOKEN to require a bearer token'}`);
|
|
1225
|
+
console.log(`[beeperbox-mcp] allowed hosts: ${[...MCP_ALLOWED_HOSTS].join(', ')}`);
|
|
1226
|
+
preflight();
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ─── stdio transport ──────────────────────────────────────────────
|
|
1231
|
+
// Newline-delimited JSON-RPC over stdin/stdout. Used when the MCP
|
|
1232
|
+
// client (Claude Code, Cursor, Cline, bareagent) spawns the server
|
|
1233
|
+
// as a subprocess — typically via `docker exec -i beeperbox node
|
|
1234
|
+
// /opt/mcp/server.js --stdio`. Stdout is reserved for the protocol;
|
|
1235
|
+
// all logging goes to stderr.
|
|
1236
|
+
|
|
1237
|
+
function startStdioTransport() {
|
|
1238
|
+
let buf = '';
|
|
1239
|
+
process.stdin.setEncoding('utf8');
|
|
1240
|
+
process.stdin.on('data', async (chunk) => {
|
|
1241
|
+
buf += chunk;
|
|
1242
|
+
let nl;
|
|
1243
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
1244
|
+
const line = buf.slice(0, nl).trim();
|
|
1245
|
+
buf = buf.slice(nl + 1);
|
|
1246
|
+
if (!line) continue;
|
|
1247
|
+
let req;
|
|
1248
|
+
try {
|
|
1249
|
+
req = JSON.parse(line);
|
|
1250
|
+
} catch {
|
|
1251
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }) + '\n');
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
const response = await handleRequest(req);
|
|
1255
|
+
if (response !== null) {
|
|
1256
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
// Do NOT process.exit() on stdin end — Node's event loop will exit
|
|
1261
|
+
// naturally once all pending async handlers settle. Exiting eagerly
|
|
1262
|
+
// drops in-flight tool responses (async fetch() to the Beeper API).
|
|
1263
|
+
// Stderr — stdout is the protocol channel and must not carry chatter.
|
|
1264
|
+
process.stderr.write('[beeperbox-mcp] stdio transport ready\n');
|
|
1265
|
+
process.stderr.write(`[beeperbox-mcp] beeper api: ${BEEPER_API}\n`);
|
|
1266
|
+
process.stderr.write(`[beeperbox-mcp] beeper token: ${BEEPER_TOKEN ? 'set' : 'NOT SET'}\n`);
|
|
1267
|
+
preflight();
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// ─── pick transport ───────────────────────────────────────────────
|
|
1271
|
+
|
|
1272
|
+
// Only boot a transport when run as the entrypoint (`node server.js` /
|
|
1273
|
+
// `--stdio`). When require()'d — by the unit tests — skip the listeners and
|
|
1274
|
+
// just expose the pure helpers below.
|
|
1275
|
+
if (require.main === module) {
|
|
1276
|
+
if (process.argv.includes('--stdio')) {
|
|
1277
|
+
startStdioTransport();
|
|
1278
|
+
} else {
|
|
1279
|
+
startHttpTransport();
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Test surface — only the pure logic the unit suite exercises (the
|
|
1284
|
+
// seed/poll/dedup bug-class core + the echo-guard matcher). The HTTP/stdio
|
|
1285
|
+
// handlers and normalizers are covered black-box by the guard/smoke scripts.
|
|
1286
|
+
module.exports = {
|
|
1287
|
+
// Parity surface — lite mode and the container run THIS file, so asserting the
|
|
1288
|
+
// version + tool names here is what guarantees the two builds can't drift.
|
|
1289
|
+
VERSION,
|
|
1290
|
+
TOOL_NAMES: TOOLS.map((t) => t.name),
|
|
1291
|
+
ledgerPath,
|
|
1292
|
+
encodeCursor,
|
|
1293
|
+
decodeCursor,
|
|
1294
|
+
isAfterCursor,
|
|
1295
|
+
advanceCursor,
|
|
1296
|
+
selectDelivery,
|
|
1297
|
+
textHash,
|
|
1298
|
+
normalizeAttachments,
|
|
1299
|
+
assertServableSrcUrl,
|
|
1300
|
+
matchSentMessage,
|
|
1301
|
+
recordSent,
|
|
1302
|
+
addResolvedId,
|
|
1303
|
+
loadLedger,
|
|
1304
|
+
// test hook: drop the in-memory ledger so a test can re-load from a fresh path
|
|
1305
|
+
_resetLedger: () => { ledger = null; ledgerPersistWarned = false; },
|
|
1306
|
+
};
|