@mulmobridge/chat-service 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/dist/atomic-write.d.ts +1 -0
- package/dist/atomic-write.js +21 -0
- package/dist/chat-state.d.ts +20 -0
- package/dist/chat-state.js +84 -0
- package/dist/commands.d.ts +12 -0
- package/dist/commands.js +75 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +120 -0
- package/dist/push-queue.d.ts +14 -0
- package/dist/push-queue.js +37 -0
- package/dist/relay.d.ts +32 -0
- package/dist/relay.js +122 -0
- package/dist/socket.d.ts +43 -0
- package/dist/socket.js +232 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +17 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @mulmobridge/chat-service
|
|
2
|
+
|
|
3
|
+
Server-side chat service for [MulmoBridge](https://github.com/receptron/mulmoclaude) — provides socket.io + REST endpoints that connect external bridges (CLI, Telegram, etc.) to a Claude Code agent.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @mulmobridge/chat-service express socket.io
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
> `express` and `socket.io` are peer dependencies.
|
|
12
|
+
|
|
13
|
+
## Overview
|
|
14
|
+
|
|
15
|
+
The chat-service is a **DI-pure factory** — all host-app concerns (agent runner, session events, role lookup, file persistence, logger) are injected via `ChatServiceDeps`. No direct imports from the host application.
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { createChatService } from "@mulmobridge/chat-service";
|
|
19
|
+
|
|
20
|
+
const chatService = createChatService({
|
|
21
|
+
startChat, // your agent entry point
|
|
22
|
+
onSessionEvent, // session event subscriber
|
|
23
|
+
loadAllRoles, // role list provider
|
|
24
|
+
getRole, // single role lookup
|
|
25
|
+
defaultRoleId, // fallback role
|
|
26
|
+
transportsDir, // directory for transport state files
|
|
27
|
+
logger, // structured logger ({ error, warn, info, debug })
|
|
28
|
+
tokenProvider, // optional: bearer token for socket.io auth
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Mount the Express router
|
|
32
|
+
app.use(chatService.router);
|
|
33
|
+
|
|
34
|
+
// Attach socket.io to the HTTP server
|
|
35
|
+
chatService.attachSocket(httpServer);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
┌─────────────┐ socket.io ┌──────────────────┐
|
|
42
|
+
│ CLI bridge │ ◄──────────────► │ chat-service │
|
|
43
|
+
│ TG bridge │ /ws/chat │ (this package) │
|
|
44
|
+
│ ... │ │ │
|
|
45
|
+
└─────────────┘ REST │ ┌─────────────┐ │
|
|
46
|
+
/api/transports │ │ relay.ts │ │ ──► startChat()
|
|
47
|
+
│ │ socket.ts │ │ ──► onSessionEvent()
|
|
48
|
+
│ │ chat-state │ │ ──► file persistence
|
|
49
|
+
│ │ commands.ts │ │ ──► /reset, /role
|
|
50
|
+
│ │ push-queue │ │ ──► server→bridge push
|
|
51
|
+
│ └─────────────┘ │
|
|
52
|
+
└──────────────────┘
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Exports
|
|
56
|
+
|
|
57
|
+
| Export | Description |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `createChatService(deps)` | Factory — returns `{ router, attachSocket, pushToBridge }` |
|
|
60
|
+
| `createRelay(deps)` | Core relay logic (HTTP + socket.io both call this) |
|
|
61
|
+
| `CHAT_SOCKET_EVENTS` | Re-exported from `@mulmobridge/protocol` |
|
|
62
|
+
| `ChatServiceDeps` | Dependency injection interface |
|
|
63
|
+
| `StartChatFn` / `StartChatParams` / `StartChatResult` | Agent entry point types |
|
|
64
|
+
| `Attachment` | File attachment interface |
|
|
65
|
+
|
|
66
|
+
## Part of the MulmoBridge ecosystem
|
|
67
|
+
|
|
68
|
+
| Package | Description |
|
|
69
|
+
|---|---|
|
|
70
|
+
| [@mulmobridge/protocol](https://www.npmjs.com/package/@mulmobridge/protocol) | Wire protocol types and constants |
|
|
71
|
+
| **@mulmobridge/chat-service** | Server-side chat service (this package) |
|
|
72
|
+
| [@mulmobridge/client](https://www.npmjs.com/package/@mulmobridge/client) | Bridge client library |
|
|
73
|
+
| [@mulmobridge/cli](https://www.npmjs.com/package/@mulmobridge/cli) | CLI bridge |
|
|
74
|
+
| [@mulmobridge/telegram](https://www.npmjs.com/package/@mulmobridge/telegram) | Telegram bridge |
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT — [Receptron Team](https://github.com/receptron)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function writeFileAtomic(filePath: string, content: string): Promise<void>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Lightweight atomic write — write to a sibling tmp file, then rename.
|
|
2
|
+
//
|
|
3
|
+
// Inlined in this package (not imported from the host app) so
|
|
4
|
+
// @mulmobridge/chat-service stays dependency-free beyond protocol.
|
|
5
|
+
// Same contract as the host's writeFileAtomic: readers always see
|
|
6
|
+
// either the old file or the new file — never a half-written one.
|
|
7
|
+
import { writeFile, rename, unlink, mkdir } from "fs/promises";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { randomUUID } from "crypto";
|
|
10
|
+
export async function writeFileAtomic(filePath, content) {
|
|
11
|
+
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
|
12
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
13
|
+
try {
|
|
14
|
+
await writeFile(tmp, content, "utf-8");
|
|
15
|
+
await rename(tmp, filePath);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
await unlink(tmp).catch(() => { });
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Logger } from "./types.js";
|
|
2
|
+
export interface TransportChatState {
|
|
3
|
+
externalChatId: string;
|
|
4
|
+
sessionId: string;
|
|
5
|
+
roleId: string;
|
|
6
|
+
claudeSessionId?: string;
|
|
7
|
+
startedAt: string;
|
|
8
|
+
updatedAt: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ChatStateStore {
|
|
11
|
+
getChatState(transportId: string, externalChatId: string): Promise<TransportChatState | null>;
|
|
12
|
+
setChatState(transportId: string, state: TransportChatState): Promise<void>;
|
|
13
|
+
resetChatState(transportId: string, externalChatId: string, roleId: string): Promise<TransportChatState>;
|
|
14
|
+
connectSession(transportId: string, externalChatId: string, chatSessionId: string): Promise<TransportChatState | null>;
|
|
15
|
+
generateSessionId(transportId: string, externalChatId: string): string;
|
|
16
|
+
}
|
|
17
|
+
export declare function createChatStateStore(opts: {
|
|
18
|
+
transportsDir: string;
|
|
19
|
+
logger: Logger;
|
|
20
|
+
}): ChatStateStore;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @package-contract — see ./types.ts
|
|
2
|
+
//
|
|
3
|
+
// Persists per-transport chat state (which session a given
|
|
4
|
+
// external chat id currently points at, which role, timestamps).
|
|
5
|
+
// Kept DI-pure so the module can be extracted as a standalone npm
|
|
6
|
+
// package: the transports directory path and logger arrive via the
|
|
7
|
+
// factory, never through a direct `../workspace-paths.js` import.
|
|
8
|
+
import { mkdir, readFile } from "fs/promises";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
11
|
+
// ── Path / id helpers ────────────────────────────────────────
|
|
12
|
+
// Allow alphanumeric, hyphen, underscore, dot. Rejects empty
|
|
13
|
+
// strings and anything >200 chars so we never let a transport id
|
|
14
|
+
// escape the transports directory via path traversal.
|
|
15
|
+
function isSafeId(id) {
|
|
16
|
+
return /^[\w.-]+$/.test(id) && id.length > 0 && id.length <= 200;
|
|
17
|
+
}
|
|
18
|
+
// ── Factory ──────────────────────────────────────────────────
|
|
19
|
+
export function createChatStateStore(opts) {
|
|
20
|
+
const { transportsDir, logger } = opts;
|
|
21
|
+
const transportDir = (transportId) => path.join(transportsDir, transportId, "chats");
|
|
22
|
+
const statePath = (transportId, externalChatId) => path.join(transportDir(transportId), `${externalChatId}.json`);
|
|
23
|
+
const generateSessionId = (transportId, externalChatId) => `${transportId}-${externalChatId}-${Date.now()}`;
|
|
24
|
+
const getChatState = async (transportId, externalChatId) => {
|
|
25
|
+
if (!isSafeId(transportId) || !isSafeId(externalChatId))
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(statePath(transportId, externalChatId), "utf-8");
|
|
29
|
+
const parsed = JSON.parse(raw);
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const setChatState = async (transportId, state) => {
|
|
37
|
+
if (!isSafeId(transportId) || !isSafeId(state.externalChatId)) {
|
|
38
|
+
throw new Error("Invalid transport or chat ID");
|
|
39
|
+
}
|
|
40
|
+
await mkdir(transportDir(transportId), { recursive: true });
|
|
41
|
+
await writeFileAtomic(statePath(transportId, state.externalChatId), JSON.stringify(state, null, 2));
|
|
42
|
+
};
|
|
43
|
+
const resetChatState = async (transportId, externalChatId, roleId) => {
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
const state = {
|
|
46
|
+
externalChatId,
|
|
47
|
+
sessionId: generateSessionId(transportId, externalChatId),
|
|
48
|
+
roleId,
|
|
49
|
+
startedAt: now,
|
|
50
|
+
updatedAt: now,
|
|
51
|
+
};
|
|
52
|
+
await setChatState(transportId, state);
|
|
53
|
+
logger.info("chat-state", "reset", {
|
|
54
|
+
transportId,
|
|
55
|
+
externalChatId,
|
|
56
|
+
sessionId: state.sessionId,
|
|
57
|
+
});
|
|
58
|
+
return state;
|
|
59
|
+
};
|
|
60
|
+
const connectSession = async (transportId, externalChatId, chatSessionId) => {
|
|
61
|
+
const existing = await getChatState(transportId, externalChatId);
|
|
62
|
+
if (!existing)
|
|
63
|
+
return null;
|
|
64
|
+
const updated = {
|
|
65
|
+
...existing,
|
|
66
|
+
sessionId: chatSessionId,
|
|
67
|
+
updatedAt: new Date().toISOString(),
|
|
68
|
+
};
|
|
69
|
+
await setChatState(transportId, updated);
|
|
70
|
+
logger.info("chat-state", "connected", {
|
|
71
|
+
transportId,
|
|
72
|
+
externalChatId,
|
|
73
|
+
sessionId: chatSessionId,
|
|
74
|
+
});
|
|
75
|
+
return updated;
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
getChatState,
|
|
79
|
+
setChatState,
|
|
80
|
+
resetChatState,
|
|
81
|
+
connectSession,
|
|
82
|
+
generateSessionId,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Role } from "./types.js";
|
|
2
|
+
import type { ChatStateStore, TransportChatState } from "./chat-state.js";
|
|
3
|
+
export interface CommandResult {
|
|
4
|
+
reply: string;
|
|
5
|
+
nextState?: TransportChatState;
|
|
6
|
+
}
|
|
7
|
+
export type CommandHandler = (text: string, transportId: string, chatState: TransportChatState) => Promise<CommandResult | null>;
|
|
8
|
+
export declare function createCommandHandler(opts: {
|
|
9
|
+
loadAllRoles: () => Role[];
|
|
10
|
+
getRole: (roleId: string) => Role;
|
|
11
|
+
resetChatState: ChatStateStore["resetChatState"];
|
|
12
|
+
}): CommandHandler;
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @package-contract — see ./types.ts
|
|
2
|
+
//
|
|
3
|
+
// Parses and executes slash commands (/reset, /help, /roles, /role,
|
|
4
|
+
// /status) for the transport chat bridge. Role lookups and state
|
|
5
|
+
// reset arrive via the factory so this file has zero imports from
|
|
6
|
+
// the host app — only sibling module types.
|
|
7
|
+
// ── Factory ──────────────────────────────────────────────────
|
|
8
|
+
export function createCommandHandler(opts) {
|
|
9
|
+
const { loadAllRoles, getRole, resetChatState } = opts;
|
|
10
|
+
const getRolesText = () => [
|
|
11
|
+
"Available roles:",
|
|
12
|
+
...loadAllRoles().map((r) => ` ${r.id} — ${r.name}`),
|
|
13
|
+
].join("\n");
|
|
14
|
+
const getHelpText = () => [
|
|
15
|
+
"Commands:",
|
|
16
|
+
" /reset — Start a new session",
|
|
17
|
+
" /help — Show this help",
|
|
18
|
+
" /roles — List available roles",
|
|
19
|
+
" /role <id> — Switch role",
|
|
20
|
+
" /status — Show current session info",
|
|
21
|
+
"",
|
|
22
|
+
"Send any other text to chat with the assistant.",
|
|
23
|
+
].join("\n");
|
|
24
|
+
const handleReset = async (transportId, chatState) => {
|
|
25
|
+
const nextState = await resetChatState(transportId, chatState.externalChatId, chatState.roleId);
|
|
26
|
+
return {
|
|
27
|
+
reply: `Session reset. Role: ${nextState.roleId}`,
|
|
28
|
+
nextState,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
const handleRole = async (transportId, chatState, requestedRoleId) => {
|
|
32
|
+
if (!requestedRoleId) {
|
|
33
|
+
return { reply: `Usage: /role <id>\n\n${getRolesText()}` };
|
|
34
|
+
}
|
|
35
|
+
const role = loadAllRoles().find((r) => r.id === requestedRoleId);
|
|
36
|
+
if (!role) {
|
|
37
|
+
return { reply: `Unknown role: ${requestedRoleId}\n\n${getRolesText()}` };
|
|
38
|
+
}
|
|
39
|
+
const nextState = await resetChatState(transportId, chatState.externalChatId, role.id);
|
|
40
|
+
return {
|
|
41
|
+
reply: `Switched to ${role.name} (${role.id}). New session started.`,
|
|
42
|
+
nextState,
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
const handleStatus = (chatState) => {
|
|
46
|
+
const role = getRole(chatState.roleId);
|
|
47
|
+
return {
|
|
48
|
+
reply: [
|
|
49
|
+
`Role: ${role.name} (${role.id})`,
|
|
50
|
+
`Session: ${chatState.sessionId}`,
|
|
51
|
+
`Last activity: ${chatState.updatedAt}`,
|
|
52
|
+
].join("\n"),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
const handleCommand = async (text, transportId, chatState) => {
|
|
56
|
+
if (!text.startsWith("/"))
|
|
57
|
+
return null;
|
|
58
|
+
const [command, ...args] = text.split(/\s+/);
|
|
59
|
+
switch (command) {
|
|
60
|
+
case "/reset":
|
|
61
|
+
return handleReset(transportId, chatState);
|
|
62
|
+
case "/help":
|
|
63
|
+
return { reply: getHelpText() };
|
|
64
|
+
case "/roles":
|
|
65
|
+
return { reply: getRolesText() };
|
|
66
|
+
case "/role":
|
|
67
|
+
return handleRole(transportId, chatState, args[0]);
|
|
68
|
+
case "/status":
|
|
69
|
+
return handleStatus(chatState);
|
|
70
|
+
default:
|
|
71
|
+
return { reply: `Unknown command: ${command}\n\n${getHelpText()}` };
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return handleCommand;
|
|
75
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type http from "http";
|
|
2
|
+
import { Router } from "express";
|
|
3
|
+
import type { RelayFn } from "./relay.js";
|
|
4
|
+
import type { PushFn } from "./socket.js";
|
|
5
|
+
import type { ChatServiceDeps } from "./types.js";
|
|
6
|
+
export interface ChatService {
|
|
7
|
+
router: Router;
|
|
8
|
+
/** Relay used by the HTTP router. Exposed so alternate transports
|
|
9
|
+
* or tests can share the same flow without going through HTTP. */
|
|
10
|
+
relay: RelayFn;
|
|
11
|
+
/** Mount the socket.io transport at `/ws/chat` on the host HTTP server. */
|
|
12
|
+
attachSocket(httpServer: http.Server): void;
|
|
13
|
+
/** Server → bridge async push (Phase B of #268). Safe to call
|
|
14
|
+
* before `attachSocket`: the message is queued and flushes on
|
|
15
|
+
* the next bridge connection for that transport. */
|
|
16
|
+
pushToBridge: PushFn;
|
|
17
|
+
}
|
|
18
|
+
export declare function createChatService(deps: ChatServiceDeps): ChatService;
|
|
19
|
+
export type { ChatServiceDeps, StartChatFn, OnSessionEventFn, } from "./types.js";
|
|
20
|
+
export { writeFileAtomic } from "./atomic-write.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @package-contract — see ./types.ts
|
|
2
|
+
//
|
|
3
|
+
// Factory for the transport chat bridge. `createChatService(deps)`
|
|
4
|
+
// returns:
|
|
5
|
+
// - an Express `Router` for the legacy HTTP transport
|
|
6
|
+
// - an `attachSocket(httpServer)` helper that mounts the socket.io
|
|
7
|
+
// transport at `/ws/chat` (Phase A of #268)
|
|
8
|
+
// - the shared `relay` function both transports dispatch through
|
|
9
|
+
// - `pushToBridge(transportId, chatId, message)` for server→bridge
|
|
10
|
+
// async push (Phase B of #268). Before `attachSocket` is called
|
|
11
|
+
// pushes go straight to the in-memory queue; once a bridge is
|
|
12
|
+
// connected they emit live and the queue drains on join.
|
|
13
|
+
//
|
|
14
|
+
// All host-app dependencies arrive via `deps`; the module has no
|
|
15
|
+
// direct imports from `../routes/…`, `../roles.js`,
|
|
16
|
+
// `../session-store/…`, or `../logger/…` so it can be lifted into a
|
|
17
|
+
// standalone npm package without internal edits. See #269 / #305.
|
|
18
|
+
import { Router } from "express";
|
|
19
|
+
import { CHAT_SERVICE_ROUTES } from "@mulmobridge/protocol";
|
|
20
|
+
import { createChatStateStore } from "./chat-state.js";
|
|
21
|
+
import { createCommandHandler } from "./commands.js";
|
|
22
|
+
import { createRelay } from "./relay.js";
|
|
23
|
+
import { createPushQueue } from "./push-queue.js";
|
|
24
|
+
import { attachChatSocket } from "./socket.js";
|
|
25
|
+
// Inlined (not imported from `../utils/httpError.js`) so the module
|
|
26
|
+
// has no outbound dependency on the host app's utility modules.
|
|
27
|
+
// See `@package-contract` in ./types.ts.
|
|
28
|
+
const badRequest = (res, error) => res.status(400).json({ error });
|
|
29
|
+
const notFound = (res, error) => res.status(404).json({ error });
|
|
30
|
+
// ── Factory ──────────────────────────────────────────────────
|
|
31
|
+
export function createChatService(deps) {
|
|
32
|
+
const { startChat, onSessionEvent, loadAllRoles, getRole, defaultRoleId, tokenProvider, } = deps;
|
|
33
|
+
const logger = deps.logger;
|
|
34
|
+
const store = createChatStateStore({
|
|
35
|
+
transportsDir: deps.transportsDir,
|
|
36
|
+
logger,
|
|
37
|
+
});
|
|
38
|
+
const handleCommand = createCommandHandler({
|
|
39
|
+
loadAllRoles,
|
|
40
|
+
getRole,
|
|
41
|
+
resetChatState: store.resetChatState,
|
|
42
|
+
});
|
|
43
|
+
const relay = createRelay({
|
|
44
|
+
store,
|
|
45
|
+
handleCommand,
|
|
46
|
+
startChat,
|
|
47
|
+
onSessionEvent,
|
|
48
|
+
getRole,
|
|
49
|
+
defaultRoleId,
|
|
50
|
+
logger,
|
|
51
|
+
});
|
|
52
|
+
const queue = createPushQueue();
|
|
53
|
+
// Until `attachSocket` runs, `livePush` is null and pushes go
|
|
54
|
+
// straight to the queue. After attach, this reference flips to
|
|
55
|
+
// the real emitter so live bridges get the message immediately.
|
|
56
|
+
// The queue is shared with the socket layer so any pushes
|
|
57
|
+
// enqueued during the pre-attach window flush on first connect.
|
|
58
|
+
let livePush = null;
|
|
59
|
+
const pushToBridge = (transportId, chatId, message) => {
|
|
60
|
+
if (livePush) {
|
|
61
|
+
livePush(transportId, chatId, message);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
queue.enqueue(transportId, { chatId, message, enqueuedAt: Date.now() });
|
|
65
|
+
logger.info("chat-service", "push queued (socket not attached yet)", {
|
|
66
|
+
transportId,
|
|
67
|
+
chatId,
|
|
68
|
+
queueSize: queue.sizeFor(transportId),
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const router = Router();
|
|
72
|
+
// POST /api/transports/:transportId/chats/:externalChatId — send text, get a reply.
|
|
73
|
+
router.post(CHAT_SERVICE_ROUTES.message, async (req, res) => {
|
|
74
|
+
const { transportId, externalChatId } = req.params;
|
|
75
|
+
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
|
76
|
+
if (!text) {
|
|
77
|
+
badRequest(res, "text is required");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const result = await relay({ transportId, externalChatId, text });
|
|
81
|
+
if (result.kind === "ok") {
|
|
82
|
+
res.json({ reply: result.reply });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
res.status(result.status).json({ reply: result.message });
|
|
86
|
+
});
|
|
87
|
+
// POST /api/transports/:transportId/chats/:externalChatId/connect —
|
|
88
|
+
// reassign the active session pointer for a transport chat.
|
|
89
|
+
router.post(CHAT_SERVICE_ROUTES.connect, async (req, res) => {
|
|
90
|
+
const { transportId, externalChatId } = req.params;
|
|
91
|
+
const chatSessionId = typeof req.body?.chatSessionId === "string"
|
|
92
|
+
? req.body.chatSessionId.trim()
|
|
93
|
+
: "";
|
|
94
|
+
if (!chatSessionId) {
|
|
95
|
+
badRequest(res, "chatSessionId is required");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const updated = await store.connectSession(transportId, externalChatId, chatSessionId);
|
|
99
|
+
if (!updated) {
|
|
100
|
+
notFound(res, "No chat state found for this transport");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
res.json({ ok: true });
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
router,
|
|
107
|
+
relay,
|
|
108
|
+
attachSocket: (httpServer) => {
|
|
109
|
+
const handle = attachChatSocket(httpServer, {
|
|
110
|
+
relay,
|
|
111
|
+
queue,
|
|
112
|
+
logger,
|
|
113
|
+
tokenProvider,
|
|
114
|
+
});
|
|
115
|
+
livePush = handle.pushToBridge;
|
|
116
|
+
},
|
|
117
|
+
pushToBridge,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export { writeFileAtomic } from "./atomic-write.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface PushMessage {
|
|
2
|
+
chatId: string;
|
|
3
|
+
message: string;
|
|
4
|
+
enqueuedAt: number;
|
|
5
|
+
}
|
|
6
|
+
export interface PushQueue {
|
|
7
|
+
/** Append a message to the transport's queue. */
|
|
8
|
+
enqueue(transportId: string, message: PushMessage): void;
|
|
9
|
+
/** Remove and return all queued messages for `transportId`. */
|
|
10
|
+
drainFor(transportId: string): PushMessage[];
|
|
11
|
+
/** How many messages are currently queued for `transportId` (test aid). */
|
|
12
|
+
sizeFor(transportId: string): number;
|
|
13
|
+
}
|
|
14
|
+
export declare function createPushQueue(): PushQueue;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @package-contract — see ./types.ts
|
|
2
|
+
//
|
|
3
|
+
// In-memory FIFO queue for server→bridge pushes that fire while no
|
|
4
|
+
// bridge socket is connected. One queue per transportId. On socket
|
|
5
|
+
// reconnect, the attaching socket's handler drains its transport's
|
|
6
|
+
// queue and emits the messages to that specific socket.
|
|
7
|
+
//
|
|
8
|
+
// Kept DI-free: no host-app imports, no fs writes. A future Phase
|
|
9
|
+
// B.2 can swap this out for a durable queue with the same interface.
|
|
10
|
+
//
|
|
11
|
+
// Not bounded — bridges reconnect quickly enough in normal operation
|
|
12
|
+
// that the steady state is zero. An adversarial producer could OOM
|
|
13
|
+
// the process; revisit if that threat model changes.
|
|
14
|
+
export function createPushQueue() {
|
|
15
|
+
const queues = new Map();
|
|
16
|
+
return {
|
|
17
|
+
enqueue(transportId, message) {
|
|
18
|
+
const existing = queues.get(transportId);
|
|
19
|
+
if (existing) {
|
|
20
|
+
existing.push(message);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
queues.set(transportId, [message]);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
drainFor(transportId) {
|
|
27
|
+
const existing = queues.get(transportId);
|
|
28
|
+
if (!existing)
|
|
29
|
+
return [];
|
|
30
|
+
queues.delete(transportId);
|
|
31
|
+
return existing;
|
|
32
|
+
},
|
|
33
|
+
sizeFor(transportId) {
|
|
34
|
+
return queues.get(transportId)?.length ?? 0;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/relay.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ChatStateStore } from "./chat-state.js";
|
|
2
|
+
import type { CommandHandler } from "./commands.js";
|
|
3
|
+
import type { Attachment, Logger, OnSessionEventFn, Role, StartChatFn } from "./types.js";
|
|
4
|
+
export interface RelayParams {
|
|
5
|
+
transportId: string;
|
|
6
|
+
externalChatId: string;
|
|
7
|
+
text: string;
|
|
8
|
+
attachments?: Attachment[];
|
|
9
|
+
/** Called for each text chunk as the agent generates it. Used by
|
|
10
|
+
* the socket transport to stream text to the bridge in real time
|
|
11
|
+
* (Phase C of #268). */
|
|
12
|
+
onChunk?: (text: string) => void;
|
|
13
|
+
}
|
|
14
|
+
export type RelayResult = {
|
|
15
|
+
kind: "ok";
|
|
16
|
+
reply: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: "error";
|
|
19
|
+
status: number;
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
export type RelayFn = (params: RelayParams) => Promise<RelayResult>;
|
|
23
|
+
export interface RelayDeps {
|
|
24
|
+
store: ChatStateStore;
|
|
25
|
+
handleCommand: CommandHandler;
|
|
26
|
+
startChat: StartChatFn;
|
|
27
|
+
onSessionEvent: OnSessionEventFn;
|
|
28
|
+
getRole: (roleId: string) => Role;
|
|
29
|
+
defaultRoleId: string;
|
|
30
|
+
logger: Logger;
|
|
31
|
+
}
|
|
32
|
+
export declare function createRelay(deps: RelayDeps): RelayFn;
|
package/dist/relay.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @package-contract — see ./types.ts
|
|
2
|
+
//
|
|
3
|
+
// Shared core of the bridge chat flow. HTTP (router) and socket.io
|
|
4
|
+
// transports both call the `RelayFn` this factory returns. DI-pure:
|
|
5
|
+
// all host-app concerns (state store, command handler, agent entry
|
|
6
|
+
// point, session events, role lookup, logger) arrive through
|
|
7
|
+
// `createRelay(deps)` so the module has no direct imports from the
|
|
8
|
+
// host.
|
|
9
|
+
import { EVENT_TYPES } from "@mulmobridge/protocol";
|
|
10
|
+
// ── Constants ────────────────────────────────────────────────
|
|
11
|
+
const REPLY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
12
|
+
// ── Factory ──────────────────────────────────────────────────
|
|
13
|
+
export function createRelay(deps) {
|
|
14
|
+
const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger, } = deps;
|
|
15
|
+
return async function relayMessage(params) {
|
|
16
|
+
const { transportId, externalChatId, text, attachments } = params;
|
|
17
|
+
// Log attachment summary (count + mimeTypes) — NEVER log raw
|
|
18
|
+
// base64 data (performance, log size, information leak risk).
|
|
19
|
+
const attachmentSummary = attachments
|
|
20
|
+
? {
|
|
21
|
+
count: attachments.length,
|
|
22
|
+
mimeTypes: attachments.map((a) => a.mimeType),
|
|
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
|
+
const defaultRole = getRole(defaultRoleId);
|
|
34
|
+
chatState = await store.resetChatState(transportId, externalChatId, defaultRole.id);
|
|
35
|
+
}
|
|
36
|
+
const commandResult = await handleCommand(text, transportId, chatState);
|
|
37
|
+
if (commandResult) {
|
|
38
|
+
return { kind: "ok", reply: commandResult.reply };
|
|
39
|
+
}
|
|
40
|
+
const result = await startChat({
|
|
41
|
+
message: text,
|
|
42
|
+
roleId: chatState.roleId,
|
|
43
|
+
chatSessionId: chatState.sessionId,
|
|
44
|
+
attachments,
|
|
45
|
+
});
|
|
46
|
+
if (result.kind === "error") {
|
|
47
|
+
const status = result.status ?? 500;
|
|
48
|
+
if (status === 409) {
|
|
49
|
+
// Session busy — tell the bridge to retry. Keep the HTTP
|
|
50
|
+
// response shape the old handler returned (status 409 on
|
|
51
|
+
// the HTTP side, "ok" reply text on the socket side — both
|
|
52
|
+
// layers decide how to serialise).
|
|
53
|
+
return {
|
|
54
|
+
kind: "ok",
|
|
55
|
+
reply: "A previous message is still being processed. Please wait.",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
logger.error("chat-service", "startChat failed", {
|
|
59
|
+
transportId,
|
|
60
|
+
externalChatId,
|
|
61
|
+
error: result.error,
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
kind: "error",
|
|
65
|
+
status,
|
|
66
|
+
message: `Error: ${result.error}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, params.onChunk);
|
|
71
|
+
await store.setChatState(transportId, {
|
|
72
|
+
...chatState,
|
|
73
|
+
updatedAt: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
return { kind: "ok", reply };
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
logger.error("chat-service", "reply collection failed", {
|
|
79
|
+
transportId,
|
|
80
|
+
externalChatId,
|
|
81
|
+
error: String(err),
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
kind: "error",
|
|
85
|
+
status: 500,
|
|
86
|
+
message: "Error: failed to collect agent reply",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// ── Internals ────────────────────────────────────────────────
|
|
92
|
+
// Kept out of the factory closure so future packaging doesn't need
|
|
93
|
+
// to re-capture anything; `onSessionEvent` arrives as a plain param.
|
|
94
|
+
function collectAgentReply(onSessionEvent, chatSessionId, onChunk) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const textChunks = [];
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
unsubscribe();
|
|
99
|
+
resolve(textChunks.join("") ||
|
|
100
|
+
"The request timed out before a reply was generated.");
|
|
101
|
+
}, REPLY_TIMEOUT_MS);
|
|
102
|
+
const unsubscribe = onSessionEvent(chatSessionId, (event) => {
|
|
103
|
+
const type = event.type;
|
|
104
|
+
if (type === EVENT_TYPES.text) {
|
|
105
|
+
const chunk = event.message;
|
|
106
|
+
textChunks.push(chunk);
|
|
107
|
+
onChunk?.(chunk);
|
|
108
|
+
}
|
|
109
|
+
if (type === EVENT_TYPES.error) {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
unsubscribe();
|
|
112
|
+
resolve(`Error: ${event.message}`);
|
|
113
|
+
}
|
|
114
|
+
if (type === EVENT_TYPES.sessionFinished) {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
unsubscribe();
|
|
117
|
+
resolve(textChunks.join("") ||
|
|
118
|
+
"The assistant completed the request but produced no text reply.");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
package/dist/socket.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type http from "http";
|
|
2
|
+
import { Server as SocketServer } from "socket.io";
|
|
3
|
+
import type { RelayFn } from "./relay.js";
|
|
4
|
+
import type { PushQueue } from "./push-queue.js";
|
|
5
|
+
import type { Logger } from "./types.js";
|
|
6
|
+
export declare const CHAT_SOCKET_PATH = "/ws/chat";
|
|
7
|
+
/**
|
|
8
|
+
* Custom socket.io events the chat transport defines. Keys mirror
|
|
9
|
+
* values so grep-and-rename is safe; the union type is what every
|
|
10
|
+
* on/emit site should reference instead of raw string literals.
|
|
11
|
+
* Socket.io built-ins (`connect`, `disconnect`, `connect_error`) are
|
|
12
|
+
* intentionally omitted — those are part of socket.io's own contract,
|
|
13
|
+
* not ours to rename.
|
|
14
|
+
*/
|
|
15
|
+
export declare const CHAT_SOCKET_EVENTS: {
|
|
16
|
+
/** bridge → server request (body: `{ externalChatId, text }`); ack
|
|
17
|
+
* carries `{ ok, reply, error?, status? }`. */
|
|
18
|
+
readonly message: "message";
|
|
19
|
+
/** server → bridge async push (Phase B of #268); body:
|
|
20
|
+
* `{ chatId, message }`. */
|
|
21
|
+
readonly push: "push";
|
|
22
|
+
/** server → bridge streaming text chunk (Phase C of #268). */
|
|
23
|
+
readonly textChunk: "textChunk";
|
|
24
|
+
};
|
|
25
|
+
export type ChatSocketEvent = (typeof CHAT_SOCKET_EVENTS)[keyof typeof CHAT_SOCKET_EVENTS];
|
|
26
|
+
export type PushFn = (transportId: string, chatId: string, message: string) => void;
|
|
27
|
+
export interface ChatSocketDeps {
|
|
28
|
+
relay: RelayFn;
|
|
29
|
+
queue: PushQueue;
|
|
30
|
+
logger: Logger;
|
|
31
|
+
/** Current bearer token the handshake must carry. Null means
|
|
32
|
+
* bootstrap in progress — reject everything. Omit to disable. */
|
|
33
|
+
tokenProvider?: () => string | null;
|
|
34
|
+
}
|
|
35
|
+
export interface ChatSocketHandle {
|
|
36
|
+
io: SocketServer;
|
|
37
|
+
/** Fire-and-forget push to every bridge in `bridge:${transportId}`.
|
|
38
|
+
* If none are connected, the message is queued for the next
|
|
39
|
+
* joiner. */
|
|
40
|
+
pushToBridge: PushFn;
|
|
41
|
+
}
|
|
42
|
+
export declare function bridgeRoom(transportId: string): string;
|
|
43
|
+
export declare function attachChatSocket(server: http.Server, deps: ChatSocketDeps): ChatSocketHandle;
|
package/dist/socket.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// @package-contract — see ./types.ts
|
|
2
|
+
//
|
|
3
|
+
// Socket.io transport for the bridge chat flow.
|
|
4
|
+
//
|
|
5
|
+
// Phase A (#268) — bridge → server req/res:
|
|
6
|
+
// handshake.auth: { transportId: string; token?: string }
|
|
7
|
+
// emit("message", { externalChatId, text }, ack)
|
|
8
|
+
// ack receives { ok: true, reply }
|
|
9
|
+
// | { ok: false, error, status? }
|
|
10
|
+
//
|
|
11
|
+
// Phase B (#268) — server → bridge async push:
|
|
12
|
+
// Each connected bridge joins room `bridge:${transportId}`.
|
|
13
|
+
// Server emits `push` { chatId, message } to that room via the
|
|
14
|
+
// `pushToBridge(transportId, chatId, message)` helper this module
|
|
15
|
+
// returns. If no sockets are in the room at push time, the
|
|
16
|
+
// message goes to an in-memory queue; the next socket that joins
|
|
17
|
+
// the room drains its transport's queue on connect.
|
|
18
|
+
//
|
|
19
|
+
// Auth: when `tokenProvider` is supplied, the handshake is rejected
|
|
20
|
+
// unless `auth.token` equals `tokenProvider()`. When omitted (tests,
|
|
21
|
+
// unauth environments) only `transportId` is validated.
|
|
22
|
+
//
|
|
23
|
+
// Future phases:
|
|
24
|
+
// C — streaming text chunks via `reply.chunk`
|
|
25
|
+
// D — HTTP endpoint deprecation
|
|
26
|
+
//
|
|
27
|
+
// See plans/feat-chat-socketio.md and plans/feat-chat-socketio-phase-b.md.
|
|
28
|
+
import { Server as SocketServer } from "socket.io";
|
|
29
|
+
export const CHAT_SOCKET_PATH = "/ws/chat";
|
|
30
|
+
/**
|
|
31
|
+
* Custom socket.io events the chat transport defines. Keys mirror
|
|
32
|
+
* values so grep-and-rename is safe; the union type is what every
|
|
33
|
+
* on/emit site should reference instead of raw string literals.
|
|
34
|
+
* Socket.io built-ins (`connect`, `disconnect`, `connect_error`) are
|
|
35
|
+
* intentionally omitted — those are part of socket.io's own contract,
|
|
36
|
+
* not ours to rename.
|
|
37
|
+
*/
|
|
38
|
+
export const CHAT_SOCKET_EVENTS = {
|
|
39
|
+
/** bridge → server request (body: `{ externalChatId, text }`); ack
|
|
40
|
+
* carries `{ ok, reply, error?, status? }`. */
|
|
41
|
+
message: "message",
|
|
42
|
+
/** server → bridge async push (Phase B of #268); body:
|
|
43
|
+
* `{ chatId, message }`. */
|
|
44
|
+
push: "push",
|
|
45
|
+
/** server → bridge streaming text chunk (Phase C of #268). */
|
|
46
|
+
textChunk: "textChunk",
|
|
47
|
+
};
|
|
48
|
+
export function bridgeRoom(transportId) {
|
|
49
|
+
return `bridge:${transportId}`;
|
|
50
|
+
}
|
|
51
|
+
export function attachChatSocket(server, deps) {
|
|
52
|
+
const { relay, queue, logger, tokenProvider } = deps;
|
|
53
|
+
const io = new SocketServer(server, {
|
|
54
|
+
path: CHAT_SOCKET_PATH,
|
|
55
|
+
// Loopback-only deployment; skip long-polling negotiation for
|
|
56
|
+
// the same reason `/ws/pubsub` does (#311).
|
|
57
|
+
transports: ["websocket"],
|
|
58
|
+
});
|
|
59
|
+
io.use((socket, next) => {
|
|
60
|
+
const result = validateHandshake(socket.handshake.auth, tokenProvider);
|
|
61
|
+
if (!result.ok) {
|
|
62
|
+
next(new Error(result.error));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
socket.data.transportId = result.transportId;
|
|
66
|
+
next();
|
|
67
|
+
});
|
|
68
|
+
io.on("connection", (socket) => {
|
|
69
|
+
const transportId = socket.data.transportId;
|
|
70
|
+
const room = bridgeRoom(transportId);
|
|
71
|
+
socket.join(room);
|
|
72
|
+
// Flush any messages queued while this transport had no live
|
|
73
|
+
// socket. Emit only to *this* socket, not the room, so a
|
|
74
|
+
// second bridge joining seconds later doesn't re-receive the
|
|
75
|
+
// already-drained messages.
|
|
76
|
+
const queued = queue.drainFor(transportId);
|
|
77
|
+
if (queued.length > 0) {
|
|
78
|
+
logger.info("chat-service", "flushing push queue", {
|
|
79
|
+
socketId: socket.id,
|
|
80
|
+
transportId,
|
|
81
|
+
count: queued.length,
|
|
82
|
+
});
|
|
83
|
+
for (const item of queued) {
|
|
84
|
+
socket.emit(CHAT_SOCKET_EVENTS.push, {
|
|
85
|
+
chatId: item.chatId,
|
|
86
|
+
message: item.message,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
logger.info("chat-service", "socket connected", {
|
|
91
|
+
socketId: socket.id,
|
|
92
|
+
transportId,
|
|
93
|
+
});
|
|
94
|
+
socket.on("disconnect", (reason) => {
|
|
95
|
+
logger.info("chat-service", "socket disconnected", {
|
|
96
|
+
socketId: socket.id,
|
|
97
|
+
transportId,
|
|
98
|
+
reason,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
socket.on(CHAT_SOCKET_EVENTS.message, async (payload, ack) => {
|
|
102
|
+
if (typeof ack !== "function") {
|
|
103
|
+
logger.warn("chat-service", "socket message missing ack", {
|
|
104
|
+
socketId: socket.id,
|
|
105
|
+
transportId,
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const parsed = parseMessagePayload(payload);
|
|
110
|
+
if (!parsed.ok) {
|
|
111
|
+
ack({ ok: false, error: parsed.error, status: 400 });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const result = await relay({
|
|
115
|
+
transportId,
|
|
116
|
+
externalChatId: parsed.externalChatId,
|
|
117
|
+
text: parsed.text,
|
|
118
|
+
attachments: parsed.attachments,
|
|
119
|
+
// Stream text chunks to this bridge socket in real time
|
|
120
|
+
// (Phase C of #268). The ack still returns the full text
|
|
121
|
+
// for backward compatibility.
|
|
122
|
+
onChunk: (text) => {
|
|
123
|
+
socket.emit(CHAT_SOCKET_EVENTS.textChunk, { text });
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
if (result.kind === "ok") {
|
|
127
|
+
ack({ ok: true, reply: result.reply });
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
ack({ ok: false, error: result.message, status: result.status });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
const pushToBridge = (transportId, chatId, message) => {
|
|
135
|
+
const room = bridgeRoom(transportId);
|
|
136
|
+
// `io.sockets.adapter.rooms` is the authoritative view of which
|
|
137
|
+
// rooms have members right now. Using it here (vs. a separate
|
|
138
|
+
// membership counter we'd have to maintain) keeps us honest
|
|
139
|
+
// about what socket.io actually knows.
|
|
140
|
+
const hasLive = (io.sockets.adapter.rooms.get(room)?.size ?? 0) > 0;
|
|
141
|
+
if (hasLive) {
|
|
142
|
+
io.to(room).emit(CHAT_SOCKET_EVENTS.push, { chatId, message });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
queue.enqueue(transportId, { chatId, message, enqueuedAt: Date.now() });
|
|
146
|
+
logger.info("chat-service", "push queued (no live bridge)", {
|
|
147
|
+
transportId,
|
|
148
|
+
chatId,
|
|
149
|
+
queueSize: queue.sizeFor(transportId),
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
return { io, pushToBridge };
|
|
153
|
+
}
|
|
154
|
+
function validateHandshake(auth, tokenProvider) {
|
|
155
|
+
if (!auth || typeof auth !== "object") {
|
|
156
|
+
return { ok: false, error: "handshake auth is required" };
|
|
157
|
+
}
|
|
158
|
+
const transportIdRaw = auth.transportId;
|
|
159
|
+
if (typeof transportIdRaw !== "string" ||
|
|
160
|
+
transportIdRaw.trim().length === 0) {
|
|
161
|
+
return { ok: false, error: "transportId is required" };
|
|
162
|
+
}
|
|
163
|
+
const transportId = transportIdRaw.trim();
|
|
164
|
+
if (!tokenProvider) {
|
|
165
|
+
return { ok: true, transportId };
|
|
166
|
+
}
|
|
167
|
+
const expected = tokenProvider();
|
|
168
|
+
if (expected === null || expected.length === 0) {
|
|
169
|
+
// Server auth not bootstrapped yet, or token absent. Reject so
|
|
170
|
+
// the bridge falls back to its connect-error path instead of
|
|
171
|
+
// silently succeeding.
|
|
172
|
+
return { ok: false, error: "server auth not ready" };
|
|
173
|
+
}
|
|
174
|
+
const provided = auth.token;
|
|
175
|
+
if (typeof provided !== "string" || provided.length === 0) {
|
|
176
|
+
return { ok: false, error: "token is required" };
|
|
177
|
+
}
|
|
178
|
+
if (provided !== expected) {
|
|
179
|
+
return { ok: false, error: "invalid token" };
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, transportId };
|
|
182
|
+
}
|
|
183
|
+
function parseMessagePayload(payload) {
|
|
184
|
+
if (!payload || typeof payload !== "object") {
|
|
185
|
+
return { ok: false, error: "payload must be an object" };
|
|
186
|
+
}
|
|
187
|
+
const externalChatId = typeof payload.externalChatId === "string"
|
|
188
|
+
? payload.externalChatId.trim()
|
|
189
|
+
: "";
|
|
190
|
+
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
|
191
|
+
if (!externalChatId) {
|
|
192
|
+
return { ok: false, error: "externalChatId is required" };
|
|
193
|
+
}
|
|
194
|
+
if (!text) {
|
|
195
|
+
return { ok: false, error: "text is required" };
|
|
196
|
+
}
|
|
197
|
+
const attachments = parseAttachments(payload.attachments);
|
|
198
|
+
return { ok: true, externalChatId, text, attachments };
|
|
199
|
+
}
|
|
200
|
+
// Hard limits to prevent oversized payloads from bridges (DoS /
|
|
201
|
+
// accidental misconfiguration). Express's JSON body limit (50 MB)
|
|
202
|
+
// is the outer gate; these are tighter, attachment-specific caps.
|
|
203
|
+
const MAX_ATTACHMENT_COUNT = 10;
|
|
204
|
+
const MAX_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024; // 20 MB base64
|
|
205
|
+
function parseAttachments(raw) {
|
|
206
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
207
|
+
return undefined;
|
|
208
|
+
const valid = [];
|
|
209
|
+
let totalBytes = 0;
|
|
210
|
+
for (const item of raw) {
|
|
211
|
+
if (valid.length >= MAX_ATTACHMENT_COUNT)
|
|
212
|
+
break;
|
|
213
|
+
if (item &&
|
|
214
|
+
typeof item === "object" &&
|
|
215
|
+
typeof item.mimeType === "string" &&
|
|
216
|
+
typeof item.data === "string") {
|
|
217
|
+
const data = item.data;
|
|
218
|
+
totalBytes += data.length;
|
|
219
|
+
if (totalBytes > MAX_ATTACHMENT_TOTAL_BYTES)
|
|
220
|
+
break;
|
|
221
|
+
const entry = {
|
|
222
|
+
mimeType: item.mimeType,
|
|
223
|
+
data,
|
|
224
|
+
};
|
|
225
|
+
const fn = item.filename;
|
|
226
|
+
if (typeof fn === "string" && fn.length > 0)
|
|
227
|
+
entry.filename = fn;
|
|
228
|
+
valid.push(entry);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return valid.length > 0 ? valid : undefined;
|
|
232
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface Role {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Logger {
|
|
6
|
+
error(prefix: string, message: string, data?: Record<string, unknown>): void;
|
|
7
|
+
warn(prefix: string, message: string, data?: Record<string, unknown>): void;
|
|
8
|
+
info(prefix: string, message: string, data?: Record<string, unknown>): void;
|
|
9
|
+
debug(prefix: string, message: string, data?: Record<string, unknown>): void;
|
|
10
|
+
}
|
|
11
|
+
/** A file attached to a bridge message. Generic enough for images,
|
|
12
|
+
* PDFs, documents, videos, etc. The server decides what to do with
|
|
13
|
+
* each based on mimeType — images become vision content blocks,
|
|
14
|
+
* unsupported types are ignored with a log. */
|
|
15
|
+
export interface Attachment {
|
|
16
|
+
mimeType: string;
|
|
17
|
+
data: string;
|
|
18
|
+
filename?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface StartChatParams {
|
|
21
|
+
message: string;
|
|
22
|
+
roleId: string;
|
|
23
|
+
chatSessionId: string;
|
|
24
|
+
selectedImageData?: string;
|
|
25
|
+
attachments?: Attachment[];
|
|
26
|
+
}
|
|
27
|
+
export type StartChatResult = {
|
|
28
|
+
kind: "started";
|
|
29
|
+
chatSessionId: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "error";
|
|
32
|
+
error: string;
|
|
33
|
+
status?: number;
|
|
34
|
+
};
|
|
35
|
+
export type StartChatFn = (params: StartChatParams) => Promise<StartChatResult>;
|
|
36
|
+
export type SessionEventListener = (event: Record<string, unknown>) => void;
|
|
37
|
+
export type OnSessionEventFn = (sessionId: string, listener: SessionEventListener) => () => void;
|
|
38
|
+
export interface ChatServiceDeps {
|
|
39
|
+
/** Relay a user turn into the agent loop. */
|
|
40
|
+
startChat: StartChatFn;
|
|
41
|
+
/** Subscribe to a session's event stream; returns an unsubscribe function. */
|
|
42
|
+
onSessionEvent: OnSessionEventFn;
|
|
43
|
+
/** All roles (built-in + custom). */
|
|
44
|
+
loadAllRoles: () => Role[];
|
|
45
|
+
/** Look up a single role by id; MUST fall back to default if unknown. */
|
|
46
|
+
getRole: (roleId: string) => Role;
|
|
47
|
+
/** Id used when a fresh transport chat has no role selected yet. */
|
|
48
|
+
defaultRoleId: string;
|
|
49
|
+
/** Absolute path to the transports workspace dir (one subdir per transportId). */
|
|
50
|
+
transportsDir: string;
|
|
51
|
+
logger: Logger;
|
|
52
|
+
/**
|
|
53
|
+
* Returns the current bearer token the socket transport should
|
|
54
|
+
* accept at handshake, or null if auth isn't bootstrapped yet.
|
|
55
|
+
* Omit in tests / unauth environments to skip the check. See
|
|
56
|
+
* `attachChatSocket` in ./socket.ts.
|
|
57
|
+
*/
|
|
58
|
+
tokenProvider?: () => string | null;
|
|
59
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @package-contract
|
|
2
|
+
//
|
|
3
|
+
// This module is designed to be extractable as a standalone npm
|
|
4
|
+
// package (e.g. `@mulmoclaude/chat-service`) at any time. To keep
|
|
5
|
+
// that path open, the rules are:
|
|
6
|
+
//
|
|
7
|
+
// 1. NO raw imports from `../` outside this directory — all host
|
|
8
|
+
// dependencies MUST be passed through `ChatServiceDeps`.
|
|
9
|
+
// 2. Types declared here are STRUCTURAL duplicates of what the
|
|
10
|
+
// host app uses. They look like the real `Role` / `Logger` /
|
|
11
|
+
// `StartChatParams` types so the same functions plug in, but
|
|
12
|
+
// they are defined here so the package has no compile-time
|
|
13
|
+
// link back to the host.
|
|
14
|
+
// 3. When you add a new dependency, extend `ChatServiceDeps` and
|
|
15
|
+
// thread it through the factory functions — do NOT reach out
|
|
16
|
+
// to a module import. See #269 / #305 for the rationale.
|
|
17
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mulmobridge/chat-service",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Server-side chat service for MulmoBridge — socket.io + REST bridge to Claude Code agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"prepack": "yarn build",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "tsx --test test/test_*.ts",
|
|
25
|
+
"lint": "eslint src test"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"author": "Receptron Team",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@mulmobridge/protocol": "^0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"express": "^5.0.0",
|
|
34
|
+
"socket.io": "^4.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/express": "^5.0.0",
|
|
38
|
+
"typescript": "^6.0.3"
|
|
39
|
+
}
|
|
40
|
+
}
|