@mulmobridge/chat-service 0.1.2 → 0.1.4
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 +41 -0
- package/dist/commands.d.ts +38 -1
- package/dist/commands.js +94 -17
- package/dist/index.js +1 -0
- package/dist/keyed-serializer.d.ts +4 -0
- package/dist/keyed-serializer.js +33 -0
- package/dist/relay.d.ts +1 -1
- package/dist/relay.js +97 -78
- package/dist/socket.d.ts +3 -1
- package/dist/socket.js +54 -16
- package/dist/types.d.ts +51 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,6 +73,47 @@ chatService.attachSocket(httpServer);
|
|
|
73
73
|
| [@mulmobridge/cli](https://www.npmjs.com/package/@mulmobridge/cli) | CLI bridge |
|
|
74
74
|
| [@mulmobridge/telegram](https://www.npmjs.com/package/@mulmobridge/telegram) | Telegram bridge |
|
|
75
75
|
|
|
76
|
+
## Ecosystem
|
|
77
|
+
|
|
78
|
+
Part of the [`@mulmobridge/*`](https://www.npmjs.com/~mulmobridge) package family.
|
|
79
|
+
|
|
80
|
+
**Shared libraries:**
|
|
81
|
+
|
|
82
|
+
- [`@mulmobridge/client`](https://www.npmjs.com/package/@mulmobridge/client) — socket.io client library used by every bridge below
|
|
83
|
+
- [`@mulmobridge/protocol`](https://www.npmjs.com/package/@mulmobridge/protocol) — wire types and constants
|
|
84
|
+
- [`@mulmobridge/chat-service`](https://www.npmjs.com/package/@mulmobridge/chat-service) — server-side relay + session store ← **this package**
|
|
85
|
+
- [`@mulmobridge/relay`](https://www.npmjs.com/package/@mulmobridge/relay) — Cloudflare Workers webhook proxy
|
|
86
|
+
- [`@mulmobridge/mock-server`](https://www.npmjs.com/package/@mulmobridge/mock-server) — mock server for local bridge development
|
|
87
|
+
|
|
88
|
+
**Bridges** (one npm package per platform):
|
|
89
|
+
|
|
90
|
+
- [`@mulmobridge/bluesky`](https://www.npmjs.com/package/@mulmobridge/bluesky) — Bluesky DMs over atproto
|
|
91
|
+
- [`@mulmobridge/chatwork`](https://www.npmjs.com/package/@mulmobridge/chatwork) — Chatwork (Japanese business chat)
|
|
92
|
+
- [`@mulmobridge/cli`](https://www.npmjs.com/package/@mulmobridge/cli) — interactive terminal bridge
|
|
93
|
+
- [`@mulmobridge/discord`](https://www.npmjs.com/package/@mulmobridge/discord) — Discord bot via Gateway
|
|
94
|
+
- [`@mulmobridge/email`](https://www.npmjs.com/package/@mulmobridge/email) — IMAP poll + SMTP reply, threading preserved
|
|
95
|
+
- [`@mulmobridge/google-chat`](https://www.npmjs.com/package/@mulmobridge/google-chat) — Google Chat via MulmoBridge relay
|
|
96
|
+
- [`@mulmobridge/irc`](https://www.npmjs.com/package/@mulmobridge/irc) — IRC (Libera, Freenode, custom)
|
|
97
|
+
- [`@mulmobridge/line`](https://www.npmjs.com/package/@mulmobridge/line) — LINE Messaging API via MulmoBridge relay
|
|
98
|
+
- [`@mulmobridge/line-works`](https://www.npmjs.com/package/@mulmobridge/line-works) — LINE Works (enterprise LINE)
|
|
99
|
+
- [`@mulmobridge/mastodon`](https://www.npmjs.com/package/@mulmobridge/mastodon) — Mastodon DMs + mentions
|
|
100
|
+
- [`@mulmobridge/matrix`](https://www.npmjs.com/package/@mulmobridge/matrix) — Matrix / Element
|
|
101
|
+
- [`@mulmobridge/mattermost`](https://www.npmjs.com/package/@mulmobridge/mattermost) — Mattermost
|
|
102
|
+
- [`@mulmobridge/messenger`](https://www.npmjs.com/package/@mulmobridge/messenger) — Facebook Messenger via MulmoBridge relay
|
|
103
|
+
- [`@mulmobridge/nostr`](https://www.npmjs.com/package/@mulmobridge/nostr) — Nostr NIP-04 encrypted DMs
|
|
104
|
+
- [`@mulmobridge/rocketchat`](https://www.npmjs.com/package/@mulmobridge/rocketchat) — Rocket.Chat
|
|
105
|
+
- [`@mulmobridge/signal`](https://www.npmjs.com/package/@mulmobridge/signal) — Signal via signal-cli-rest-api
|
|
106
|
+
- [`@mulmobridge/slack`](https://www.npmjs.com/package/@mulmobridge/slack) — Slack Socket Mode
|
|
107
|
+
- [`@mulmobridge/teams`](https://www.npmjs.com/package/@mulmobridge/teams) — Microsoft Teams via Bot Framework
|
|
108
|
+
- [`@mulmobridge/telegram`](https://www.npmjs.com/package/@mulmobridge/telegram) — Telegram bot
|
|
109
|
+
- [`@mulmobridge/twilio-sms`](https://www.npmjs.com/package/@mulmobridge/twilio-sms) — SMS via Twilio Programmable Messaging
|
|
110
|
+
- [`@mulmobridge/viber`](https://www.npmjs.com/package/@mulmobridge/viber) — Viber Public Account bots
|
|
111
|
+
- [`@mulmobridge/webhook`](https://www.npmjs.com/package/@mulmobridge/webhook) — generic HTTP webhook bridge
|
|
112
|
+
- [`@mulmobridge/whatsapp`](https://www.npmjs.com/package/@mulmobridge/whatsapp) — WhatsApp Cloud API via MulmoBridge relay
|
|
113
|
+
- [`@mulmobridge/xmpp`](https://www.npmjs.com/package/@mulmobridge/xmpp) — XMPP / Jabber
|
|
114
|
+
- [`@mulmobridge/zulip`](https://www.npmjs.com/package/@mulmobridge/zulip) — Zulip
|
|
115
|
+
|
|
116
|
+
|
|
76
117
|
## License
|
|
77
118
|
|
|
78
119
|
MIT — [Receptron Team](https://github.com/receptron)
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
|
-
import type { Role, SessionSummary } from "./types.js";
|
|
1
|
+
import type { BridgeSkillSummary, Role, SessionSummary } from "./types.js";
|
|
2
2
|
import type { ChatStateStore, TransportChatState } from "./chat-state.js";
|
|
3
3
|
export interface CommandResult {
|
|
4
4
|
reply: string;
|
|
5
5
|
nextState?: TransportChatState;
|
|
6
|
+
/** When set, the relay must NOT short-circuit with `reply`. It
|
|
7
|
+
* adopts `nextState` as the active chat state and forwards
|
|
8
|
+
* `forwardAs` to the agent as the user message. Used by the
|
|
9
|
+
* `//{skill}` shortcut: reset + run skill in one bridge turn. */
|
|
10
|
+
forwardAs?: string;
|
|
6
11
|
}
|
|
7
12
|
export type CommandHandler = (text: string, transportId: string, chatState: TransportChatState) => Promise<CommandResult | null>;
|
|
13
|
+
/**
|
|
14
|
+
* Split a `//{skill} [args...]` shortcut into the skill name and the
|
|
15
|
+
* verbatim argument text.
|
|
16
|
+
*
|
|
17
|
+
* The separator between the skill name and args is the FIRST run of
|
|
18
|
+
* whitespace (a single space, multiple spaces, a tab, a CRLF — the
|
|
19
|
+
* bridge can't disambiguate "long separator" from "short separator
|
|
20
|
+
* plus leading whitespace in args", so the simple choice is to drop
|
|
21
|
+
* the whole leading run as one logical separator). Whitespace INSIDE
|
|
22
|
+
* the args (after the first run) is preserved verbatim, so doubled
|
|
23
|
+
* spaces, tabs, and newlines pasted into a multi-line prompt all
|
|
24
|
+
* survive.
|
|
25
|
+
*
|
|
26
|
+
* Exported for unit tests; callers in the handler use it directly.
|
|
27
|
+
*
|
|
28
|
+
* Examples:
|
|
29
|
+
* "//mag2" → { skillName: "mag2", argsVerbatim: "" }
|
|
30
|
+
* "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
|
|
31
|
+
* "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
|
|
32
|
+
* "//mag2\r\nurl" → { skillName: "mag2", argsVerbatim: "url" }
|
|
33
|
+
* "//mag2 a b\tc" → { skillName: "mag2", argsVerbatim: "a b\tc" }
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseSkillShortcut(text: string): {
|
|
36
|
+
skillName: string;
|
|
37
|
+
argsVerbatim: string;
|
|
38
|
+
};
|
|
8
39
|
export declare function createCommandHandler(opts: {
|
|
9
40
|
loadAllRoles: () => Role[];
|
|
10
41
|
getRole: (roleId: string) => Role;
|
|
@@ -27,4 +58,10 @@ export declare function createCommandHandler(opts: {
|
|
|
27
58
|
}>;
|
|
28
59
|
total: number;
|
|
29
60
|
}>;
|
|
61
|
+
/** Lists the skills the bridge command handler should expose.
|
|
62
|
+
* Drives both the slash-command allowlist (only matching names
|
|
63
|
+
* are forwarded to the agent) and the "Skills:" section in the
|
|
64
|
+
* `/help` reply. When omitted, every unknown slash is rejected
|
|
65
|
+
* and `/help` shows only the built-in commands. */
|
|
66
|
+
listRegisteredSkills?: () => Promise<BridgeSkillSummary[]>;
|
|
30
67
|
}): CommandHandler;
|
package/dist/commands.js
CHANGED
|
@@ -22,9 +22,43 @@ function formatRelativeTime(isoDate) {
|
|
|
22
22
|
const days = Math.floor(diffMs / ONE_DAY_MS);
|
|
23
23
|
return `${days}d ago`;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Split a `//{skill} [args...]` shortcut into the skill name and the
|
|
27
|
+
* verbatim argument text.
|
|
28
|
+
*
|
|
29
|
+
* The separator between the skill name and args is the FIRST run of
|
|
30
|
+
* whitespace (a single space, multiple spaces, a tab, a CRLF — the
|
|
31
|
+
* bridge can't disambiguate "long separator" from "short separator
|
|
32
|
+
* plus leading whitespace in args", so the simple choice is to drop
|
|
33
|
+
* the whole leading run as one logical separator). Whitespace INSIDE
|
|
34
|
+
* the args (after the first run) is preserved verbatim, so doubled
|
|
35
|
+
* spaces, tabs, and newlines pasted into a multi-line prompt all
|
|
36
|
+
* survive.
|
|
37
|
+
*
|
|
38
|
+
* Exported for unit tests; callers in the handler use it directly.
|
|
39
|
+
*
|
|
40
|
+
* Examples:
|
|
41
|
+
* "//mag2" → { skillName: "mag2", argsVerbatim: "" }
|
|
42
|
+
* "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
|
|
43
|
+
* "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
|
|
44
|
+
* "//mag2\r\nurl" → { skillName: "mag2", argsVerbatim: "url" }
|
|
45
|
+
* "//mag2 a b\tc" → { skillName: "mag2", argsVerbatim: "a b\tc" }
|
|
46
|
+
*/
|
|
47
|
+
export function parseSkillShortcut(text) {
|
|
48
|
+
const sepIdx = text.search(/\s/);
|
|
49
|
+
if (sepIdx < 0)
|
|
50
|
+
return { skillName: text.slice(2), argsVerbatim: "" };
|
|
51
|
+
const head = text.slice(0, sepIdx);
|
|
52
|
+
// Strip the full leading whitespace run — \s in JS already matches
|
|
53
|
+
// \r, \n, \t, \v, \f, NBSP (U+00A0), and the rest of Unicode whitespace,
|
|
54
|
+
// so CRLF separators collapse to nothing instead of leaving an orphan
|
|
55
|
+
// \n in argsVerbatim (Codex review iter-1).
|
|
56
|
+
const argsVerbatim = text.slice(sepIdx).replace(/^\s+/, "");
|
|
57
|
+
return { skillName: head.slice(2), argsVerbatim };
|
|
58
|
+
}
|
|
25
59
|
// ── Factory ──────────────────────────────────────────────────
|
|
26
60
|
export function createCommandHandler(opts) {
|
|
27
|
-
const { loadAllRoles, getRole, resetChatState, connectSession, listSessions, getSessionHistory } = opts;
|
|
61
|
+
const { loadAllRoles, getRole, resetChatState, connectSession, listSessions, getSessionHistory, listRegisteredSkills } = opts;
|
|
28
62
|
// Cache /sessions results per chat so /switch resolves to the correct list.
|
|
29
63
|
// Key: "transportId:externalChatId". Bounded with max entries + TTL.
|
|
30
64
|
// See docs/bridge-session-design.md for multi-user scaling plan.
|
|
@@ -51,19 +85,30 @@ export function createCommandHandler(opts) {
|
|
|
51
85
|
sessionListCache.set(key, { sessions, createdAt: Date.now() });
|
|
52
86
|
}
|
|
53
87
|
const getRolesText = () => ["Available roles:", ...loadAllRoles().map((r) => ` ${r.id} — ${r.name}`)].join("\n");
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
88
|
+
// Built each time so `/help` reflects the live skill list. The
|
|
89
|
+
// `skills` argument is fetched once per command turn (see the
|
|
90
|
+
// `default:` branch and `case "/help"`) so we don't fs-scan twice
|
|
91
|
+
// when the handler both checks membership and renders help.
|
|
92
|
+
const buildHelpText = (skills) => {
|
|
93
|
+
const lines = [
|
|
94
|
+
"Commands:",
|
|
95
|
+
" /reset — Start a new session",
|
|
96
|
+
" /sessions [page] — List recent sessions (e.g. /sessions 2)",
|
|
97
|
+
" /switch <number|sessionId> — Switch to a session",
|
|
98
|
+
" /history [page] — Show recent messages in current session",
|
|
99
|
+
" /help — Show this help",
|
|
100
|
+
" /roles — List available roles",
|
|
101
|
+
" /role <id> — Switch role",
|
|
102
|
+
" /status — Show current session info",
|
|
103
|
+
];
|
|
104
|
+
if (skills.length > 0) {
|
|
105
|
+
lines.push("", "Skills:", ...skills.map((s) => ` /${s.name} — ${s.description}`));
|
|
106
|
+
lines.push("", "Tip: //<skill> [args...] starts a fresh session and runs the skill in one shot.");
|
|
107
|
+
}
|
|
108
|
+
lines.push("", "Send any other text to chat with the assistant.");
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
};
|
|
111
|
+
const fetchSkills = async () => (listRegisteredSkills ? await listRegisteredSkills() : []);
|
|
67
112
|
const handleReset = async (transportId, chatState) => {
|
|
68
113
|
const nextState = await resetChatState(transportId, chatState.externalChatId, chatState.roleId);
|
|
69
114
|
return {
|
|
@@ -211,6 +256,26 @@ export function createCommandHandler(opts) {
|
|
|
211
256
|
const handleCommand = async (text, transportId, chatState) => {
|
|
212
257
|
if (!text.startsWith("/"))
|
|
213
258
|
return null;
|
|
259
|
+
// `//{skill} [args...]` shortcut — start a new session AND run
|
|
260
|
+
// the skill in one bridge turn. Args after the skill name are
|
|
261
|
+
// forwarded verbatim — split at the FIRST whitespace character
|
|
262
|
+
// and preserve everything after it (including doubled spaces /
|
|
263
|
+
// tabs) so payloads like a URL with internal whitespace, or
|
|
264
|
+
// multi-paragraph prompts, aren't silently rewritten.
|
|
265
|
+
if (text.startsWith("//")) {
|
|
266
|
+
const skills = await fetchSkills();
|
|
267
|
+
const { skillName, argsVerbatim } = parseSkillShortcut(text);
|
|
268
|
+
if (skillName && skills.some((s) => s.name === skillName)) {
|
|
269
|
+
const nextState = await resetChatState(transportId, chatState.externalChatId, chatState.roleId);
|
|
270
|
+
const forwardAs = argsVerbatim.length > 0 ? `/${skillName} ${argsVerbatim}` : `/${skillName}`;
|
|
271
|
+
return {
|
|
272
|
+
reply: `Session reset. Running ${forwardAs}`,
|
|
273
|
+
nextState,
|
|
274
|
+
forwardAs,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return { reply: `Unknown command: ${text}\n\n${buildHelpText(skills)}` };
|
|
278
|
+
}
|
|
214
279
|
const [command, ...args] = text.split(/\s+/);
|
|
215
280
|
switch (command) {
|
|
216
281
|
case "/reset":
|
|
@@ -222,15 +287,27 @@ export function createCommandHandler(opts) {
|
|
|
222
287
|
case "/history":
|
|
223
288
|
return handleHistory(chatState, args[0]);
|
|
224
289
|
case "/help":
|
|
225
|
-
return { reply:
|
|
290
|
+
return { reply: buildHelpText(await fetchSkills()) };
|
|
226
291
|
case "/roles":
|
|
227
292
|
return { reply: getRolesText() };
|
|
228
293
|
case "/role":
|
|
229
294
|
return handleRole(transportId, chatState, args[0]);
|
|
230
295
|
case "/status":
|
|
231
296
|
return handleStatus(chatState);
|
|
232
|
-
default:
|
|
233
|
-
|
|
297
|
+
default: {
|
|
298
|
+
// Forward to the agent only if the command names a registered
|
|
299
|
+
// skill; otherwise reply with the standard "Unknown command"
|
|
300
|
+
// help. We deliberately do NOT pass arbitrary slash text
|
|
301
|
+
// through, so a typo can't accidentally invoke the agent and
|
|
302
|
+
// a slash that doesn't match anything stays a transport-level
|
|
303
|
+
// error. Reuse the same skill list for the membership check
|
|
304
|
+
// and the help text to avoid scanning the skills dir twice.
|
|
305
|
+
const skills = await fetchSkills();
|
|
306
|
+
const skillName = command.slice(1);
|
|
307
|
+
if (skillName && skills.some((s) => s.name === skillName))
|
|
308
|
+
return null;
|
|
309
|
+
return { reply: `Unknown command: ${command}\n\n${buildHelpText(skills)}` };
|
|
310
|
+
}
|
|
234
311
|
}
|
|
235
312
|
};
|
|
236
313
|
return handleCommand;
|
package/dist/index.js
CHANGED
|
@@ -42,6 +42,7 @@ export function createChatService(deps) {
|
|
|
42
42
|
connectSession: store.connectSession,
|
|
43
43
|
listSessions: deps.listSessions,
|
|
44
44
|
getSessionHistory: deps.getSessionHistory,
|
|
45
|
+
listRegisteredSkills: deps.listRegisteredSkills,
|
|
45
46
|
});
|
|
46
47
|
const relay = createRelay({
|
|
47
48
|
store,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @package-contract — see ./types.ts
|
|
2
|
+
//
|
|
3
|
+
// Per-key serialization primitive. `run(key, task)` guarantees that
|
|
4
|
+
// for any single key, tasks execute one-at-a-time in call order;
|
|
5
|
+
// tasks under different keys run concurrently. The bridge relay uses
|
|
6
|
+
// it to serialize all turns for one external chat so two concurrent
|
|
7
|
+
// first messages can't each create a separate session (#1878).
|
|
8
|
+
//
|
|
9
|
+
// In-memory and DI-free, matching push-queue.ts: a single server
|
|
10
|
+
// process owns every relay turn. Each per-key chain entry is dropped
|
|
11
|
+
// once its tail settles, so the map doesn't grow without bound.
|
|
12
|
+
export function createKeyedSerializer() {
|
|
13
|
+
const tails = new Map();
|
|
14
|
+
return {
|
|
15
|
+
run(key, task) {
|
|
16
|
+
// `prev` is a swallowed tail that never rejects, so the next
|
|
17
|
+
// task always starts regardless of how the previous one settled.
|
|
18
|
+
const prev = tails.get(key) ?? Promise.resolve();
|
|
19
|
+
const result = prev.then(() => task());
|
|
20
|
+
// Swallow the outcome (so the chain never breaks), then drop the
|
|
21
|
+
// map entry — but only when no later task has chained onto this
|
|
22
|
+
// tail, so unrelated keys never leave stale entries behind.
|
|
23
|
+
const tail = result
|
|
24
|
+
.then(() => { }, () => { })
|
|
25
|
+
.then(() => {
|
|
26
|
+
if (tails.get(key) === tail)
|
|
27
|
+
tails.delete(key);
|
|
28
|
+
});
|
|
29
|
+
tails.set(key, tail);
|
|
30
|
+
return result;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
package/dist/relay.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface RelayParams {
|
|
|
10
10
|
* number / boolean values only — see socket.ts sanitiser).
|
|
11
11
|
* Forwarded to the host app's startChat callback as
|
|
12
12
|
* `bridgeOptions`. Empty when the bridge didn't send any.
|
|
13
|
-
* See plans/feat-bridge-options-passthrough.md. */
|
|
13
|
+
* See plans/done/feat-bridge-options-passthrough.md. */
|
|
14
14
|
bridgeOptions?: Readonly<Record<string, string | number | boolean>>;
|
|
15
15
|
/** Called for each text chunk as the agent generates it. Used by
|
|
16
16
|
* the socket transport to stream text to the bridge in real time
|
package/dist/relay.js
CHANGED
|
@@ -7,95 +7,114 @@
|
|
|
7
7
|
// `createRelay(deps)` so the module has no direct imports from the
|
|
8
8
|
// host.
|
|
9
9
|
import { EVENT_TYPES } from "@mulmobridge/protocol";
|
|
10
|
+
import { createKeyedSerializer } from "./keyed-serializer.js";
|
|
10
11
|
// ── Constants ────────────────────────────────────────────────
|
|
11
12
|
const REPLY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
12
13
|
// ── Factory ──────────────────────────────────────────────────
|
|
13
14
|
export function createRelay(deps) {
|
|
15
|
+
const serialize = createKeyedSerializer();
|
|
16
|
+
return function relayMessage(params) {
|
|
17
|
+
// Serialize every turn for one external chat. Without this, two
|
|
18
|
+
// concurrent first messages each read "no state", create separate
|
|
19
|
+
// sessions, and split the conversation across them (#1878). The
|
|
20
|
+
// JSON pair is an unambiguous composite key for the two ids.
|
|
21
|
+
const key = JSON.stringify([params.transportId, params.externalChatId]);
|
|
22
|
+
return serialize.run(key, () => processRelayMessage(deps, params));
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function processRelayMessage(deps, params) {
|
|
14
26
|
const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger } = deps;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
: undefined;
|
|
25
|
-
logger.info("chat-service", "message received", {
|
|
26
|
-
transportId,
|
|
27
|
-
externalChatId,
|
|
28
|
-
textLength: text.length,
|
|
29
|
-
...(attachmentSummary ? { attachments: attachmentSummary } : {}),
|
|
30
|
-
});
|
|
31
|
-
let chatState = await store.getChatState(transportId, externalChatId);
|
|
32
|
-
if (!chatState) {
|
|
33
|
-
// Only on FIRST contact do we honour `bridgeOptions.defaultRole`
|
|
34
|
-
// — once the session exists, whatever role the user / command
|
|
35
|
-
// handler settled on is the source of truth. An unknown role
|
|
36
|
-
// id silently falls back to the host-app default (we log it so
|
|
37
|
-
// a typo in the bridge's env var is discoverable).
|
|
38
|
-
const resolved = resolveDefaultRole(bridgeOptions, getRole, defaultRoleId, logger, transportId);
|
|
39
|
-
chatState = await store.resetChatState(transportId, externalChatId, resolved);
|
|
27
|
+
const { transportId, externalChatId, attachments, bridgeOptions } = params;
|
|
28
|
+
let { text } = params;
|
|
29
|
+
// Log attachment summary (count + mimeTypes) — NEVER log raw
|
|
30
|
+
// base64 data (performance, log size, information leak risk).
|
|
31
|
+
const attachmentSummary = attachments
|
|
32
|
+
? {
|
|
33
|
+
count: attachments.length,
|
|
34
|
+
mimeTypes: attachments.map((a) => a.mimeType),
|
|
40
35
|
}
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
: undefined;
|
|
37
|
+
logger.info("chat-service", "message received", {
|
|
38
|
+
transportId,
|
|
39
|
+
externalChatId,
|
|
40
|
+
textLength: text.length,
|
|
41
|
+
...(attachmentSummary ? { attachments: attachmentSummary } : {}),
|
|
42
|
+
});
|
|
43
|
+
let chatState = await store.getChatState(transportId, externalChatId);
|
|
44
|
+
if (!chatState) {
|
|
45
|
+
// Only on FIRST contact do we honour `bridgeOptions.defaultRole`
|
|
46
|
+
// — once the session exists, whatever role the user / command
|
|
47
|
+
// handler settled on is the source of truth. An unknown role
|
|
48
|
+
// id silently falls back to the host-app default (we log it so
|
|
49
|
+
// a typo in the bridge's env var is discoverable).
|
|
50
|
+
const resolved = resolveDefaultRole(bridgeOptions, getRole, defaultRoleId, logger, transportId);
|
|
51
|
+
chatState = await store.resetChatState(transportId, externalChatId, resolved);
|
|
52
|
+
}
|
|
53
|
+
const commandResult = await handleCommand(text, transportId, chatState);
|
|
54
|
+
if (commandResult) {
|
|
55
|
+
// `forwardAs` means "reset/mutate state AND continue into the
|
|
56
|
+
// agent with rewritten text" (see //{skill} shortcut). Without
|
|
57
|
+
// it, short-circuit with the canned reply.
|
|
58
|
+
if (!commandResult.forwardAs) {
|
|
43
59
|
return { kind: "ok", reply: commandResult.reply };
|
|
44
60
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
logger.error("chat-service", "startChat failed", {
|
|
68
|
-
transportId,
|
|
69
|
-
externalChatId,
|
|
70
|
-
error: result.error,
|
|
71
|
-
});
|
|
72
|
-
return {
|
|
73
|
-
kind: "error",
|
|
74
|
-
status,
|
|
75
|
-
message: `Error: ${result.error}`,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
try {
|
|
79
|
-
const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, params.onChunk);
|
|
80
|
-
await store.setChatState(transportId, {
|
|
81
|
-
...chatState,
|
|
82
|
-
updatedAt: new Date().toISOString(),
|
|
83
|
-
});
|
|
84
|
-
return { kind: "ok", reply };
|
|
85
|
-
}
|
|
86
|
-
catch (err) {
|
|
87
|
-
logger.error("chat-service", "reply collection failed", {
|
|
88
|
-
transportId,
|
|
89
|
-
externalChatId,
|
|
90
|
-
error: String(err),
|
|
91
|
-
});
|
|
61
|
+
if (commandResult.nextState)
|
|
62
|
+
chatState = commandResult.nextState;
|
|
63
|
+
text = commandResult.forwardAs;
|
|
64
|
+
}
|
|
65
|
+
const result = await startChat({
|
|
66
|
+
message: text,
|
|
67
|
+
roleId: chatState.roleId,
|
|
68
|
+
chatSessionId: chatState.sessionId,
|
|
69
|
+
attachments,
|
|
70
|
+
origin: "bridge",
|
|
71
|
+
// Host app may use other keys (e.g. a future `defaultModel`);
|
|
72
|
+
// we forward the whole bag untouched.
|
|
73
|
+
bridgeOptions,
|
|
74
|
+
});
|
|
75
|
+
if (result.kind === "error") {
|
|
76
|
+
const status = result.status ?? 500;
|
|
77
|
+
if (status === 409) {
|
|
78
|
+
// Session busy — tell the bridge to retry. Keep the HTTP
|
|
79
|
+
// response shape the old handler returned (status 409 on
|
|
80
|
+
// the HTTP side, "ok" reply text on the socket side — both
|
|
81
|
+
// layers decide how to serialise).
|
|
92
82
|
return {
|
|
93
|
-
kind: "
|
|
94
|
-
|
|
95
|
-
message: "Error: failed to collect agent reply",
|
|
83
|
+
kind: "ok",
|
|
84
|
+
reply: "A previous message is still being processed. Please wait.",
|
|
96
85
|
};
|
|
97
86
|
}
|
|
98
|
-
|
|
87
|
+
logger.error("chat-service", "startChat failed", {
|
|
88
|
+
transportId,
|
|
89
|
+
externalChatId,
|
|
90
|
+
error: result.error,
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
kind: "error",
|
|
94
|
+
status,
|
|
95
|
+
message: `Error: ${result.error}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, params.onChunk);
|
|
100
|
+
await store.setChatState(transportId, {
|
|
101
|
+
...chatState,
|
|
102
|
+
updatedAt: new Date().toISOString(),
|
|
103
|
+
});
|
|
104
|
+
return { kind: "ok", reply };
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
logger.error("chat-service", "reply collection failed", {
|
|
108
|
+
transportId,
|
|
109
|
+
externalChatId,
|
|
110
|
+
error: String(err),
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
kind: "error",
|
|
114
|
+
status: 500,
|
|
115
|
+
message: "Error: failed to collect agent reply",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
99
118
|
}
|
|
100
119
|
// ── Internals ────────────────────────────────────────────────
|
|
101
120
|
// Resolve the role id to seed a NEW bridge chat state with. Prefers
|
package/dist/socket.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type http from "http";
|
|
|
2
2
|
import { Server as SocketServer } from "socket.io";
|
|
3
3
|
import type { RelayFn } from "./relay.js";
|
|
4
4
|
import type { PushQueue } from "./push-queue.js";
|
|
5
|
-
import type { Logger } from "./types.js";
|
|
5
|
+
import type { Attachment, Logger } from "./types.js";
|
|
6
6
|
export declare const CHAT_SOCKET_PATH = "/ws/chat";
|
|
7
7
|
/**
|
|
8
8
|
* Custom socket.io events the chat transport defines. Keys mirror
|
|
@@ -43,4 +43,6 @@ type BridgeOptions = Readonly<Record<string, string | number | boolean>>;
|
|
|
43
43
|
export declare function bridgeRoom(transportId: string): string;
|
|
44
44
|
export declare function attachChatSocket(server: http.Server, deps: ChatSocketDeps): ChatSocketHandle;
|
|
45
45
|
export declare function sanitiseOptions(raw: unknown): BridgeOptions;
|
|
46
|
+
export declare function parseAttachments(raw: unknown): Attachment[] | undefined;
|
|
47
|
+
export declare function parseOneAttachment(item: unknown): Attachment | null;
|
|
46
48
|
export {};
|
package/dist/socket.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// C — streaming text chunks via `reply.chunk`
|
|
25
25
|
// D — HTTP endpoint deprecation
|
|
26
26
|
//
|
|
27
|
-
// See plans/feat-chat-socketio.md and plans/feat-chat-socketio-phase-b.md.
|
|
27
|
+
// See plans/done/feat-chat-socketio.md and plans/done/feat-chat-socketio-phase-b.md.
|
|
28
28
|
import { Server as SocketServer } from "socket.io";
|
|
29
29
|
export const CHAT_SOCKET_PATH = "/ws/chat";
|
|
30
30
|
/**
|
|
@@ -237,7 +237,7 @@ function parseMessagePayload(payload) {
|
|
|
237
237
|
// is the outer gate; these are tighter, attachment-specific caps.
|
|
238
238
|
const MAX_ATTACHMENT_COUNT = 10;
|
|
239
239
|
const MAX_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024; // 20 MB base64
|
|
240
|
-
function parseAttachments(raw) {
|
|
240
|
+
export function parseAttachments(raw) {
|
|
241
241
|
if (!Array.isArray(raw) || raw.length === 0)
|
|
242
242
|
return undefined;
|
|
243
243
|
const valid = [];
|
|
@@ -245,23 +245,61 @@ function parseAttachments(raw) {
|
|
|
245
245
|
for (const item of raw) {
|
|
246
246
|
if (valid.length >= MAX_ATTACHMENT_COUNT)
|
|
247
247
|
break;
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
totalBytes += data.length;
|
|
248
|
+
const entry = parseOneAttachment(item);
|
|
249
|
+
if (!entry)
|
|
250
|
+
continue;
|
|
251
|
+
if (entry.data) {
|
|
252
|
+
totalBytes += entry.data.length;
|
|
254
253
|
if (totalBytes > MAX_ATTACHMENT_TOTAL_BYTES)
|
|
255
254
|
break;
|
|
256
|
-
const entry = {
|
|
257
|
-
mimeType: item.mimeType,
|
|
258
|
-
data,
|
|
259
|
-
};
|
|
260
|
-
const fn = item.filename;
|
|
261
|
-
if (typeof fn === "string" && fn.length > 0)
|
|
262
|
-
entry.filename = fn;
|
|
263
|
-
valid.push(entry);
|
|
264
255
|
}
|
|
256
|
+
valid.push(entry);
|
|
265
257
|
}
|
|
266
258
|
return valid.length > 0 ? valid : undefined;
|
|
267
259
|
}
|
|
260
|
+
// Accept either the inline `{ data, mimeType }` shape (bridges
|
|
261
|
+
// shipping raw bytes) or the path-only `{ path }` shape (Vue UI and
|
|
262
|
+
// any bridge that uploads to `data/attachments/` first). Either is
|
|
263
|
+
// valid per the exported `Attachment` type. The earlier parser only
|
|
264
|
+
// recognised the first shape, so a typed `{ path }` payload from a
|
|
265
|
+
// socket client passed type-check but got silently dropped at the
|
|
266
|
+
// wire boundary (#1050 review).
|
|
267
|
+
//
|
|
268
|
+
// Wire-boundary path guard: a malicious bridge can ship arbitrary
|
|
269
|
+
// strings as `path`. The downstream `isAttachmentPath` enforcement
|
|
270
|
+
// in the server is the authoritative gate, but defence-in-depth at
|
|
271
|
+
// each layer is required for external code (#1099 review). Reject
|
|
272
|
+
// absolute paths and any traversal segment up front so a payload
|
|
273
|
+
// like `path: "../../etc/passwd"` never reaches the relay.
|
|
274
|
+
// Windows-style drive-letter absolute path (`C:\…` or `C:/…`).
|
|
275
|
+
// `isSafeAttachmentPath` rejects these too — a malicious bridge
|
|
276
|
+
// can't ship a host-absolute path under any platform convention
|
|
277
|
+
// (#1099 review iter-2).
|
|
278
|
+
const WINDOWS_DRIVE_PATH_RE = /^[A-Za-z]:[\\/]/;
|
|
279
|
+
function isSafeAttachmentPath(value) {
|
|
280
|
+
if (value.length === 0)
|
|
281
|
+
return false;
|
|
282
|
+
if (value.startsWith("/") || value.startsWith("\\"))
|
|
283
|
+
return false;
|
|
284
|
+
if (WINDOWS_DRIVE_PATH_RE.test(value))
|
|
285
|
+
return false;
|
|
286
|
+
for (const segment of value.split(/[/\\]/)) {
|
|
287
|
+
if (segment === "..")
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
export function parseOneAttachment(item) {
|
|
293
|
+
if (!item || typeof item !== "object")
|
|
294
|
+
return null;
|
|
295
|
+
const record = item;
|
|
296
|
+
const filename = typeof record.filename === "string" && record.filename.length > 0 ? record.filename : undefined;
|
|
297
|
+
if (typeof record.mimeType === "string" && typeof record.data === "string") {
|
|
298
|
+
return { mimeType: record.mimeType, data: record.data, ...(filename ? { filename } : {}) };
|
|
299
|
+
}
|
|
300
|
+
if (typeof record.path === "string" && isSafeAttachmentPath(record.path)) {
|
|
301
|
+
const mimeType = typeof record.mimeType === "string" ? record.mimeType : undefined;
|
|
302
|
+
return { path: record.path, ...(mimeType ? { mimeType } : {}), ...(filename ? { filename } : {}) };
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -8,19 +8,42 @@ export interface Logger {
|
|
|
8
8
|
info(prefix: string, message: string, data?: Record<string, unknown>): void;
|
|
9
9
|
debug(prefix: string, message: string, data?: Record<string, unknown>): void;
|
|
10
10
|
}
|
|
11
|
-
/** A file attached to a bridge message. Generic enough for
|
|
12
|
-
* PDFs, documents, videos, etc. The server decides what to
|
|
13
|
-
* each based on mimeType — images become vision content
|
|
14
|
-
* unsupported types are ignored with a log.
|
|
11
|
+
/** A file attached to a bridge or UI message. Generic enough for
|
|
12
|
+
* images, PDFs, documents, videos, etc. The server decides what to
|
|
13
|
+
* do with each based on mimeType — images become vision content
|
|
14
|
+
* blocks, unsupported types are ignored with a log.
|
|
15
|
+
*
|
|
16
|
+
* Either `data` (inline base64 bytes) or `path` (workspace-relative
|
|
17
|
+
* path the server can read) MUST be set:
|
|
18
|
+
*
|
|
19
|
+
* - Bridges over the socket transport ship raw bytes, so they
|
|
20
|
+
* populate `data` (and usually `mimeType`).
|
|
21
|
+
* - The Vue UI uploads paste/drop and sidebar-pick files to disk
|
|
22
|
+
* before sending and populates `path`; the server reads bytes
|
|
23
|
+
* from disk and infers `mimeType` from the extension.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors `@mulmobridge/protocol`'s `Attachment` (kept structurally
|
|
26
|
+
* duplicated here per the package-contract rules in this file). */
|
|
15
27
|
export interface Attachment {
|
|
16
|
-
mimeType
|
|
17
|
-
data
|
|
28
|
+
mimeType?: string;
|
|
29
|
+
data?: string;
|
|
30
|
+
path?: string;
|
|
18
31
|
filename?: string;
|
|
19
32
|
}
|
|
20
33
|
export interface StartChatParams {
|
|
21
34
|
message: string;
|
|
22
35
|
roleId: string;
|
|
23
36
|
chatSessionId: string;
|
|
37
|
+
/** Bridge-only legacy carrier for "the user picked this image".
|
|
38
|
+
* No in-tree bridge populates this today; the field stays on the
|
|
39
|
+
* type so external bridge clients on older protocol versions still
|
|
40
|
+
* type-check. Only workspace paths are accepted — `data:` URLs
|
|
41
|
+
* are no longer supported and the host app drops them with a
|
|
42
|
+
* warn. Bridges that need to ship raw bytes should use the
|
|
43
|
+
* modern `attachments[]` field with `{ mimeType, data }` entries;
|
|
44
|
+
* the host app persists those to `data/attachments/YYYY/MM/`
|
|
45
|
+
* server-side and rewrites them as path-bearing attachments
|
|
46
|
+
* before any other processing. */
|
|
24
47
|
selectedImageData?: string;
|
|
25
48
|
attachments?: Attachment[];
|
|
26
49
|
/** Session origin — application-defined (e.g. "human", "bridge") */
|
|
@@ -99,4 +122,26 @@ export interface ChatServiceDeps {
|
|
|
99
122
|
}>;
|
|
100
123
|
total: number;
|
|
101
124
|
}>;
|
|
125
|
+
/**
|
|
126
|
+
* Return the skills the bridge command handler should expose. The
|
|
127
|
+
* handler uses the result for two things:
|
|
128
|
+
* (1) Decide whether an unknown bridge slash command (e.g. `/foo`
|
|
129
|
+
* from Telegram) names a registered skill — only matching
|
|
130
|
+
* names are forwarded to the agent so the Claude CLI's
|
|
131
|
+
* slash-command resolver runs the skill. Non-matches stay a
|
|
132
|
+
* transport-level "Unknown command" reply.
|
|
133
|
+
* (2) Render a "Skills:" section in the bridge `/help` text and
|
|
134
|
+
* in the "Unknown command" fallback so a bridge user can
|
|
135
|
+
* discover what skills exist without leaving the chat.
|
|
136
|
+
* When omitted, every unknown slash is rejected and `/help` shows
|
|
137
|
+
* only the built-in commands.
|
|
138
|
+
*/
|
|
139
|
+
listRegisteredSkills?: () => Promise<BridgeSkillSummary[]>;
|
|
140
|
+
}
|
|
141
|
+
/** Minimal skill info the bridge command handler needs to render the
|
|
142
|
+
* `/help` text and decide whether a slash command should be forwarded
|
|
143
|
+
* to the agent. Sourced from SKILL.md frontmatter on the host side. */
|
|
144
|
+
export interface BridgeSkillSummary {
|
|
145
|
+
name: string;
|
|
146
|
+
description: string;
|
|
102
147
|
}
|
package/package.json
CHANGED