agent-social-mcp 0.2.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 +29 -0
- package/USAGE.md +7 -0
- package/dist/client/cli.js +13 -0
- package/dist/client/relayAuth.js +20 -0
- package/dist/client/remoteMcpServer.js +142 -0
- package/dist/companion/chatServer.js +369 -0
- package/dist/companion/chatUi.js +866 -0
- package/dist/companion/cli.js +18 -0
- package/dist/companion/mcpUi.js +234 -0
- package/dist/companion/relayClient.js +26 -0
- package/dist/companion/replyAssistance.js +164 -0
- package/dist/companion/state.js +344 -0
- package/dist/companion/syncState.js +50 -0
- package/dist/contracts.js +111 -0
- package/dist/datingContracts.js +1 -0
- package/dist/publicViews.js +115 -0
- package/dist/socialContracts.js +1 -0
- package/package.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Agent Social MCP Client
|
|
2
|
+
|
|
3
|
+
Agent Social installs anonymous social discovery, realtime conversation, and Agent-assisted reply analysis into compatible Agent hosts.
|
|
4
|
+
|
|
5
|
+
Node.js 22 or newer is required. For Codex, add:
|
|
6
|
+
|
|
7
|
+
```toml
|
|
8
|
+
[mcp_servers.agent_social]
|
|
9
|
+
command = "npx"
|
|
10
|
+
args = ["-y", "agent-social-mcp@0.2.0"]
|
|
11
|
+
|
|
12
|
+
[mcp_servers.agent_social.env]
|
|
13
|
+
AGENT_SOCIAL_RELAY_URL = "https://agent-social.shenglongai.top"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Restart Codex, then ask the Agent to confirm that `dating_onboard`, `dating_request_match`, and `dating_open_chat` are available. Installation and version checks must not onboard, consume an invite, create a match, or send a message.
|
|
17
|
+
|
|
18
|
+
The localhost fallback is:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
AGENT_SOCIAL_RELAY_URL=https://agent-social.shenglongai.top \
|
|
22
|
+
npx -y -p agent-social-mcp@0.2.0 agent-social-chat
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Open `http://127.0.0.1:8787` on that computer. The default owner-only identity and chat state remain under `$HOME/.agent-social-mcp`; an existing `AGENT_SOCIAL_STORE_PATH` remains compatible.
|
|
26
|
+
|
|
27
|
+
This public package contains the local client only. It does not contain the central relay, database adapters, deployment code, test harness, operator keys, invites, user credentials, or production data.
|
|
28
|
+
|
|
29
|
+
See `USAGE.md` for the preview usage status and safety boundaries.
|
package/USAGE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Preview Usage Notice
|
|
2
|
+
|
|
3
|
+
This `0.2.0` package is an `UNLICENSED` public preview. Public registry availability permits installation and evaluation of the Agent Social client but is not an open-source license grant.
|
|
4
|
+
|
|
5
|
+
Do not redistribute credentials, copy another user's local state, bypass invite onboarding, automate social message sending, or use the client to access a relay without authorization.
|
|
6
|
+
|
|
7
|
+
The client does not verify identity, age, gender, devices, photos, or real-world safety. Users control outbound messages and should use report, block, and close controls when appropriate.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createRemoteDatingMcpServer } from "./remoteMcpServer.js";
|
|
6
|
+
const relayUrl = process.env.AGENT_SOCIAL_RELAY_URL?.trim();
|
|
7
|
+
if (!relayUrl) {
|
|
8
|
+
throw new Error("AGENT_SOCIAL_RELAY_URL is required for the public Agent Social client");
|
|
9
|
+
}
|
|
10
|
+
const storePath = process.env.AGENT_SOCIAL_STORE_PATH
|
|
11
|
+
?? join(homedir(), ".agent-social-mcp", "state.json");
|
|
12
|
+
const server = createRemoteDatingMcpServer({ relayUrl, storePath });
|
|
13
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const actorFields = {
|
|
2
|
+
dating_onboard: "existingToken",
|
|
3
|
+
dating_request_match: "userToken",
|
|
4
|
+
dating_get_match_status: "userToken",
|
|
5
|
+
dating_send_message: "senderToken",
|
|
6
|
+
dating_receive_messages: "recipientToken",
|
|
7
|
+
dating_mark_read: "readerToken",
|
|
8
|
+
dating_report_session: "reporterToken",
|
|
9
|
+
dating_block_user: "blockerToken",
|
|
10
|
+
dating_close_session: "requesterToken"
|
|
11
|
+
};
|
|
12
|
+
export function actorTokenForClientCall(tool, input) {
|
|
13
|
+
const field = actorFields[tool];
|
|
14
|
+
const value = field ? input[field] : undefined;
|
|
15
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
export function authorizationForClientRelayCall(tool, input) {
|
|
18
|
+
const token = actorTokenForClientCall(tool, input);
|
|
19
|
+
return token ? `Bearer ${token}` : undefined;
|
|
20
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { registerCompanionMcpUi } from "../companion/mcpUi.js";
|
|
4
|
+
import { syncCompanionState } from "../companion/syncState.js";
|
|
5
|
+
import { CompanionStateStore, defaultCompanionStatePath } from "../companion/state.js";
|
|
6
|
+
import { MCP_TOOLS } from "../contracts.js";
|
|
7
|
+
import { authorizationForClientRelayCall } from "./relayAuth.js";
|
|
8
|
+
const optionalDateString = z.string().datetime().optional();
|
|
9
|
+
const datingIntentSchema = z.enum(["light_chat", "flirt", "relationship"]);
|
|
10
|
+
const genderPreferenceSchema = z.enum(["men", "women", "any"]);
|
|
11
|
+
export function createRemoteDatingMcpServer(options) {
|
|
12
|
+
const relayUrl = normalizedRelayUrl(options.relayUrl);
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: "agent-social-mcp",
|
|
15
|
+
version: "0.2.0"
|
|
16
|
+
});
|
|
17
|
+
registerRemoteTool(server, options, relayUrl, MCP_TOOLS.onboard, "Dating Onboard", "Create or reuse a persistent anonymous dating relay token.", {
|
|
18
|
+
existingToken: z.string().optional(),
|
|
19
|
+
inviteCode: z.string(),
|
|
20
|
+
declaredAdult: z.boolean(),
|
|
21
|
+
now: optionalDateString
|
|
22
|
+
});
|
|
23
|
+
registerRemoteTool(server, options, relayUrl, MCP_TOOLS.requestMatch, "Dating Request Match", "Submit a temporary match capsule and try to create one compatible match session.", {
|
|
24
|
+
userToken: z.string(),
|
|
25
|
+
datingIntent: datingIntentSchema,
|
|
26
|
+
genderPreference: genderPreferenceSchema,
|
|
27
|
+
desiredTone: z.string(),
|
|
28
|
+
topics: z.array(z.string()).min(1),
|
|
29
|
+
boundaries: z.array(z.string()).default([]),
|
|
30
|
+
shareableIntro: z.string(),
|
|
31
|
+
ttlMinutes: z.number().int().min(1).max(1440),
|
|
32
|
+
createdAt: optionalDateString
|
|
33
|
+
});
|
|
34
|
+
registerRemoteTool(server, options, relayUrl, MCP_TOOLS.getMatchStatus, "Dating Get Match Status", "Read active or historical match sessions for an authenticated anonymous user.", {
|
|
35
|
+
userToken: z.string()
|
|
36
|
+
});
|
|
37
|
+
registerRemoteTool(server, options, relayUrl, MCP_TOOLS.sendMessage, "Dating Send Message", "Relay an outbound dating message only after explicit user confirmation.", {
|
|
38
|
+
id: z.string().optional(),
|
|
39
|
+
sessionId: z.string(),
|
|
40
|
+
senderToken: z.string(),
|
|
41
|
+
body: z.string(),
|
|
42
|
+
userConfirmed: z.boolean(),
|
|
43
|
+
createdAt: optionalDateString
|
|
44
|
+
});
|
|
45
|
+
registerRemoteTool(server, options, relayUrl, MCP_TOOLS.receiveMessages, "Dating Receive Messages", "Fetch inbound messages for a session with structured reply context for the user's Agent.", {
|
|
46
|
+
sessionId: z.string(),
|
|
47
|
+
recipientToken: z.string(),
|
|
48
|
+
cursor: z.string().optional()
|
|
49
|
+
});
|
|
50
|
+
registerRemoteTool(server, options, relayUrl, MCP_TOOLS.markRead, "Dating Mark Read", "Mark peer messages in an active session as read by the authenticated user.", {
|
|
51
|
+
sessionId: z.string(),
|
|
52
|
+
readerToken: z.string(),
|
|
53
|
+
readAt: optionalDateString
|
|
54
|
+
});
|
|
55
|
+
registerRemoteTool(server, options, relayUrl, MCP_TOOLS.reportSession, "Dating Report Session", "Report a match session and retain available message context for safety review.", {
|
|
56
|
+
id: z.string().optional(),
|
|
57
|
+
sessionId: z.string(),
|
|
58
|
+
reporterToken: z.string(),
|
|
59
|
+
reason: z.string(),
|
|
60
|
+
createdAt: optionalDateString
|
|
61
|
+
});
|
|
62
|
+
registerRemoteTool(server, options, relayUrl, MCP_TOOLS.blockUser, "Dating Block User", "Block the peer in a match session without exposing their credential.", {
|
|
63
|
+
sessionId: z.string(),
|
|
64
|
+
blockerToken: z.string(),
|
|
65
|
+
createdAt: optionalDateString
|
|
66
|
+
});
|
|
67
|
+
registerRemoteTool(server, options, relayUrl, MCP_TOOLS.closeSession, "Dating Close Session", "Close an active match session for one of its participants.", {
|
|
68
|
+
sessionId: z.string(),
|
|
69
|
+
requesterToken: z.string()
|
|
70
|
+
});
|
|
71
|
+
const stateStore = new CompanionStateStore(defaultCompanionStatePath({
|
|
72
|
+
...process.env,
|
|
73
|
+
AGENT_SOCIAL_STORE_PATH: options.storePath
|
|
74
|
+
}));
|
|
75
|
+
registerCompanionMcpUi(server, {
|
|
76
|
+
stateStore,
|
|
77
|
+
relay: {
|
|
78
|
+
call: (tool, input) => postRemoteRelayTool(options, relayUrl, tool, input)
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return server;
|
|
82
|
+
}
|
|
83
|
+
function registerRemoteTool(server, options, relayUrl, name, title, description, inputSchema) {
|
|
84
|
+
const registerTool = server.registerTool.bind(server);
|
|
85
|
+
registerTool(name, { title, description, inputSchema }, async (input) => {
|
|
86
|
+
const rawInput = input;
|
|
87
|
+
const payload = await postRemoteRelayTool(options, relayUrl, name, rawInput);
|
|
88
|
+
if (payload.ok !== false) {
|
|
89
|
+
syncCompanionState(options.storePath, name, payload, rawInput);
|
|
90
|
+
}
|
|
91
|
+
return payload.ok === false
|
|
92
|
+
? toolError(String(payload.error ?? "relay error"), payload)
|
|
93
|
+
: toolSuccess(payload);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function postRemoteRelayTool(options, relayUrl, tool, input) {
|
|
97
|
+
const authorization = authorizationForClientRelayCall(tool, input);
|
|
98
|
+
try {
|
|
99
|
+
const response = await (options.fetchImpl ?? fetch)(new URL("/api/relay", relayUrl), {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: {
|
|
102
|
+
"content-type": "application/json",
|
|
103
|
+
...(authorization ? { authorization } : {})
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({ tool, input })
|
|
106
|
+
});
|
|
107
|
+
const payload = (await response.json());
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: typeof payload.error === "string" ? payload.error : `relay http ${response.status}`
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return payload;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return { ok: false, error: "relay request failed" };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function normalizedRelayUrl(value) {
|
|
121
|
+
if (!value.trim()) {
|
|
122
|
+
throw new Error("AGENT_SOCIAL_RELAY_URL is required");
|
|
123
|
+
}
|
|
124
|
+
const url = new URL(value);
|
|
125
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
126
|
+
throw new Error("AGENT_SOCIAL_RELAY_URL must use http or https");
|
|
127
|
+
}
|
|
128
|
+
return url;
|
|
129
|
+
}
|
|
130
|
+
function toolSuccess(payload) {
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: "text", text: "OK" }],
|
|
133
|
+
structuredContent: JSON.parse(JSON.stringify(payload))
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function toolError(message, payload) {
|
|
137
|
+
return {
|
|
138
|
+
content: [{ type: "text", text: message }],
|
|
139
|
+
structuredContent: JSON.parse(JSON.stringify(payload)),
|
|
140
|
+
isError: true
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { MCP_TOOLS } from "../contracts.js";
|
|
3
|
+
import { RelayClient } from "./relayClient.js";
|
|
4
|
+
import { buildReplyAnalysisContext, shouldAutoAnalyzeReply } from "./replyAssistance.js";
|
|
5
|
+
import { chatHtml as sharedChatHtml } from "./chatUi.js";
|
|
6
|
+
export function createChatServer(options) {
|
|
7
|
+
const events = new CompanionEventHub(options);
|
|
8
|
+
return createServer(async (request, response) => {
|
|
9
|
+
if (request.method === "GET" && new URL(request.url ?? "/", "http://localhost").pathname === "/api/events") {
|
|
10
|
+
events.attach(request, response);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const result = await handleChatHttpRequest(options, {
|
|
15
|
+
method: request.method,
|
|
16
|
+
url: request.url,
|
|
17
|
+
body: await readRawBody(request)
|
|
18
|
+
});
|
|
19
|
+
writeResponse(response, result);
|
|
20
|
+
if (request.method === "POST" && result.statusCode < 400) {
|
|
21
|
+
events.publishState();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
writeResponse(response, jsonResponse(500, {
|
|
26
|
+
ok: false,
|
|
27
|
+
error: error instanceof Error ? error.message : "chat internal error"
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
class CompanionEventHub {
|
|
33
|
+
options;
|
|
34
|
+
clients = new Set();
|
|
35
|
+
timer;
|
|
36
|
+
syncing = false;
|
|
37
|
+
retryMs = 1000;
|
|
38
|
+
constructor(options) {
|
|
39
|
+
this.options = options;
|
|
40
|
+
}
|
|
41
|
+
attach(request, response) {
|
|
42
|
+
response.writeHead(200, {
|
|
43
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
44
|
+
"cache-control": "no-cache, no-transform",
|
|
45
|
+
connection: "keep-alive"
|
|
46
|
+
});
|
|
47
|
+
response.write("retry: 1000\n\n");
|
|
48
|
+
this.clients.add(response);
|
|
49
|
+
this.publishState();
|
|
50
|
+
this.schedule(0);
|
|
51
|
+
request.on("close", () => {
|
|
52
|
+
this.clients.delete(response);
|
|
53
|
+
if (this.clients.size === 0 && this.timer) {
|
|
54
|
+
clearTimeout(this.timer);
|
|
55
|
+
this.timer = undefined;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
publishState() {
|
|
60
|
+
this.broadcast("state", summarizeState(this.options.stateStore.read()));
|
|
61
|
+
}
|
|
62
|
+
schedule(delay) {
|
|
63
|
+
if (this.clients.size === 0 || this.timer) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.timer = setTimeout(() => {
|
|
67
|
+
this.timer = undefined;
|
|
68
|
+
void this.tick();
|
|
69
|
+
}, delay);
|
|
70
|
+
this.timer.unref();
|
|
71
|
+
}
|
|
72
|
+
async tick() {
|
|
73
|
+
if (this.syncing || this.clients.size === 0) {
|
|
74
|
+
this.schedule(this.retryMs);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.syncing = true;
|
|
78
|
+
try {
|
|
79
|
+
const relay = new RelayClient({ relayUrl: this.options.relayUrl });
|
|
80
|
+
await syncMessages(this.options.stateStore, relay);
|
|
81
|
+
this.retryMs = 1000;
|
|
82
|
+
this.broadcast("status", { online: true, syncedAt: new Date().toISOString() });
|
|
83
|
+
this.publishState();
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
this.retryMs = Math.min(this.retryMs * 2, 15000);
|
|
87
|
+
this.broadcast("status", {
|
|
88
|
+
online: false,
|
|
89
|
+
error: error instanceof Error ? error.message : "sync failed",
|
|
90
|
+
retryMs: this.retryMs
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
this.syncing = false;
|
|
95
|
+
this.schedule(this.retryMs);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
broadcast(event, payload) {
|
|
99
|
+
const frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
100
|
+
for (const client of this.clients) {
|
|
101
|
+
client.write(frame);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function handleChatHttpRequest(options, request) {
|
|
106
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
107
|
+
const method = request.method ?? "GET";
|
|
108
|
+
const relay = new RelayClient({ relayUrl: options.relayUrl });
|
|
109
|
+
if (method === "GET" && url.pathname === "/") {
|
|
110
|
+
return htmlResponse(sharedChatHtml);
|
|
111
|
+
}
|
|
112
|
+
if (method === "GET" && url.pathname === "/api/state") {
|
|
113
|
+
return jsonResponse(200, summarizeState(options.stateStore.read()));
|
|
114
|
+
}
|
|
115
|
+
if (method === "POST" && url.pathname === "/api/state") {
|
|
116
|
+
const body = parseJsonBody(request.body);
|
|
117
|
+
const userToken = stringValue(body.userToken);
|
|
118
|
+
if (!userToken) {
|
|
119
|
+
return jsonResponse(400, { ok: false, error: "userToken is required" });
|
|
120
|
+
}
|
|
121
|
+
return jsonResponse(200, summarizeState(options.stateStore.setUserToken(userToken)));
|
|
122
|
+
}
|
|
123
|
+
if (method === "POST" && url.pathname === "/api/sessions/sync") {
|
|
124
|
+
const state = await syncSessions(options.stateStore, relay);
|
|
125
|
+
return jsonResponse(200, summarizeState(state));
|
|
126
|
+
}
|
|
127
|
+
if (method === "POST" && url.pathname === "/api/messages/sync") {
|
|
128
|
+
const body = parseJsonBody(request.body);
|
|
129
|
+
const state = await syncMessages(options.stateStore, relay, stringOrUndefined(body.sessionId));
|
|
130
|
+
return jsonResponse(200, summarizeState(state));
|
|
131
|
+
}
|
|
132
|
+
if (method === "POST" && url.pathname === "/api/messages/send") {
|
|
133
|
+
const body = parseJsonBody(request.body);
|
|
134
|
+
const result = await sendMessage(options.stateStore, relay, body);
|
|
135
|
+
return jsonResponse(200, result);
|
|
136
|
+
}
|
|
137
|
+
if (method === "POST" && url.pathname === "/api/preferences") {
|
|
138
|
+
const body = parseJsonBody(request.body);
|
|
139
|
+
if (typeof body.autoAnalyze !== "boolean") {
|
|
140
|
+
return jsonResponse(400, { ok: false, error: "autoAnalyze must be boolean" });
|
|
141
|
+
}
|
|
142
|
+
return jsonResponse(200, summarizeState(options.stateStore.setAutoAnalyze(body.autoAnalyze)));
|
|
143
|
+
}
|
|
144
|
+
if (method === "POST" && url.pathname === "/api/sessions/read") {
|
|
145
|
+
const body = parseJsonBody(request.body);
|
|
146
|
+
const sessionId = stringValue(body.sessionId);
|
|
147
|
+
if (!sessionId) {
|
|
148
|
+
return jsonResponse(400, { ok: false, error: "sessionId is required" });
|
|
149
|
+
}
|
|
150
|
+
return jsonResponse(200, await markSessionRead(options.stateStore, relay, sessionId));
|
|
151
|
+
}
|
|
152
|
+
if (method === "POST" && url.pathname === "/api/sessions/report") {
|
|
153
|
+
const body = parseJsonBody(request.body);
|
|
154
|
+
return jsonResponse(200, await runSessionAction(options.stateStore, relay, "report", body));
|
|
155
|
+
}
|
|
156
|
+
if (method === "POST" && url.pathname === "/api/sessions/block") {
|
|
157
|
+
const body = parseJsonBody(request.body);
|
|
158
|
+
return jsonResponse(200, await runSessionAction(options.stateStore, relay, "block", body));
|
|
159
|
+
}
|
|
160
|
+
if (method === "POST" && url.pathname === "/api/sessions/close") {
|
|
161
|
+
const body = parseJsonBody(request.body);
|
|
162
|
+
return jsonResponse(200, await runSessionAction(options.stateStore, relay, "close", body));
|
|
163
|
+
}
|
|
164
|
+
return jsonResponse(404, { ok: false, error: "not found" });
|
|
165
|
+
}
|
|
166
|
+
export async function syncSessions(stateStore, relay) {
|
|
167
|
+
const state = stateStore.read();
|
|
168
|
+
if (!state.userToken) {
|
|
169
|
+
return state;
|
|
170
|
+
}
|
|
171
|
+
const payload = await relay.call(MCP_TOOLS.getMatchStatus, { userToken: state.userToken });
|
|
172
|
+
if (payload.ok === false) {
|
|
173
|
+
throw new Error(String(payload.error ?? "failed to sync sessions"));
|
|
174
|
+
}
|
|
175
|
+
return stateStore.upsertSessions((payload.sessions ?? []));
|
|
176
|
+
}
|
|
177
|
+
export async function syncMessages(stateStore, relay, sessionId) {
|
|
178
|
+
let state = await syncSessions(stateStore, relay);
|
|
179
|
+
if (!state.userToken) {
|
|
180
|
+
return state;
|
|
181
|
+
}
|
|
182
|
+
const sessions = state.sessions.filter((session) => session.status === "active" && (!sessionId || session.id === sessionId));
|
|
183
|
+
for (const session of sessions) {
|
|
184
|
+
const payload = await relay.call(MCP_TOOLS.receiveMessages, {
|
|
185
|
+
sessionId: session.id,
|
|
186
|
+
recipientToken: state.userToken,
|
|
187
|
+
...(session.cursor ? { cursor: session.cursor } : {})
|
|
188
|
+
});
|
|
189
|
+
if (payload.ok === false) {
|
|
190
|
+
if (sessionId) {
|
|
191
|
+
throw new Error(String(payload.error ?? "failed to sync messages"));
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const messages = (payload.messages ?? []);
|
|
196
|
+
state = stateStore.upsertMessages(messages);
|
|
197
|
+
state = stateStore.setSessionCursor(session.id, typeof payload.cursor === "string" ? payload.cursor : undefined);
|
|
198
|
+
}
|
|
199
|
+
return state;
|
|
200
|
+
}
|
|
201
|
+
export async function sendMessage(stateStore, relay, body) {
|
|
202
|
+
const state = stateStore.read();
|
|
203
|
+
if (!state.userToken) {
|
|
204
|
+
return { ok: false, error: "userToken is required" };
|
|
205
|
+
}
|
|
206
|
+
const sessionId = stringValue(body.sessionId);
|
|
207
|
+
const text = stringValue(body.body);
|
|
208
|
+
if (!sessionId) {
|
|
209
|
+
return { ok: false, error: "sessionId is required" };
|
|
210
|
+
}
|
|
211
|
+
if (!text.trim()) {
|
|
212
|
+
return { ok: false, error: "body is required" };
|
|
213
|
+
}
|
|
214
|
+
const payload = await relay.call(MCP_TOOLS.sendMessage, {
|
|
215
|
+
sessionId,
|
|
216
|
+
senderToken: state.userToken,
|
|
217
|
+
body: text,
|
|
218
|
+
userConfirmed: true
|
|
219
|
+
});
|
|
220
|
+
if (payload.ok === false) {
|
|
221
|
+
return payload;
|
|
222
|
+
}
|
|
223
|
+
stateStore.upsertMessages([payload.message], "sent");
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
state: summarizeState(stateStore.read())
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
export function summarizeState(state) {
|
|
230
|
+
const analysisTargets = state.sessions.flatMap((session) => {
|
|
231
|
+
const context = buildReplyAnalysisContext({
|
|
232
|
+
sessionId: session.id,
|
|
233
|
+
peerIntro: session.peerIntro,
|
|
234
|
+
matchContext: state.matchContext,
|
|
235
|
+
messages: state.messages.filter((message) => message.sessionId === session.id)
|
|
236
|
+
});
|
|
237
|
+
if (!context) {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
const assistance = state.replyAssistance.find((candidate) => candidate.sessionId === session.id);
|
|
241
|
+
const request = state.replyAssistanceRequests.find((candidate) => candidate.sessionId === session.id);
|
|
242
|
+
const status = request?.contextHash === context.contextHash
|
|
243
|
+
? "analyzing"
|
|
244
|
+
: assistance?.contextHash === context.contextHash
|
|
245
|
+
? "ready"
|
|
246
|
+
: assistance
|
|
247
|
+
? "stale"
|
|
248
|
+
: "idle";
|
|
249
|
+
return [{
|
|
250
|
+
sessionId: session.id,
|
|
251
|
+
sourceMessageId: context.sourceMessageId,
|
|
252
|
+
contextHash: context.contextHash,
|
|
253
|
+
autoEligible: shouldAutoAnalyzeReply(context),
|
|
254
|
+
status,
|
|
255
|
+
...(request ? { requestCreatedAt: request.createdAt } : {})
|
|
256
|
+
}];
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
ok: true,
|
|
260
|
+
connected: Boolean(state.userToken),
|
|
261
|
+
preferences: state.preferences,
|
|
262
|
+
sessions: state.sessions.map((session) => {
|
|
263
|
+
const messages = state.messages.filter((message) => message.sessionId === session.id);
|
|
264
|
+
const lastMessage = messages.at(-1);
|
|
265
|
+
const unreadCount = messages.filter((message) => message.direction === "received" && !message.readAt).length;
|
|
266
|
+
return {
|
|
267
|
+
...session,
|
|
268
|
+
createdAt: session.createdAt,
|
|
269
|
+
unreadCount,
|
|
270
|
+
lastMessageAt: lastMessage?.createdAt,
|
|
271
|
+
lastMessagePreview: lastMessage?.body
|
|
272
|
+
};
|
|
273
|
+
}),
|
|
274
|
+
messages: state.messages,
|
|
275
|
+
analysisTargets,
|
|
276
|
+
replyAssistance: state.replyAssistance
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
export async function markSessionRead(stateStore, relay, sessionId) {
|
|
280
|
+
const state = stateStore.read();
|
|
281
|
+
if (!state.userToken) {
|
|
282
|
+
return { ok: false, error: "userToken is required" };
|
|
283
|
+
}
|
|
284
|
+
const payload = await relay.call(MCP_TOOLS.markRead, {
|
|
285
|
+
sessionId,
|
|
286
|
+
readerToken: state.userToken,
|
|
287
|
+
readAt: new Date().toISOString()
|
|
288
|
+
});
|
|
289
|
+
if (payload.ok === false) {
|
|
290
|
+
return payload;
|
|
291
|
+
}
|
|
292
|
+
stateStore.markSessionOpened(sessionId);
|
|
293
|
+
await syncMessages(stateStore, relay, sessionId);
|
|
294
|
+
return summarizeState(stateStore.read());
|
|
295
|
+
}
|
|
296
|
+
export async function runSessionAction(stateStore, relay, action, body) {
|
|
297
|
+
const state = stateStore.read();
|
|
298
|
+
const sessionId = stringValue(body.sessionId);
|
|
299
|
+
if (!state.userToken) {
|
|
300
|
+
return { ok: false, error: "userToken is required" };
|
|
301
|
+
}
|
|
302
|
+
if (!sessionId) {
|
|
303
|
+
return { ok: false, error: "sessionId is required" };
|
|
304
|
+
}
|
|
305
|
+
const calls = {
|
|
306
|
+
report: {
|
|
307
|
+
tool: MCP_TOOLS.reportSession,
|
|
308
|
+
input: {
|
|
309
|
+
sessionId,
|
|
310
|
+
reporterToken: state.userToken,
|
|
311
|
+
reason: stringValue(body.reason) || "User reported this session from the live chat UI.",
|
|
312
|
+
createdAt: new Date().toISOString()
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
block: {
|
|
316
|
+
tool: MCP_TOOLS.blockUser,
|
|
317
|
+
input: { sessionId, blockerToken: state.userToken, createdAt: new Date().toISOString() }
|
|
318
|
+
},
|
|
319
|
+
close: {
|
|
320
|
+
tool: MCP_TOOLS.closeSession,
|
|
321
|
+
input: { sessionId, requesterToken: state.userToken }
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const call = calls[action];
|
|
325
|
+
const payload = await relay.call(call.tool, call.input);
|
|
326
|
+
if (payload.ok === false) {
|
|
327
|
+
return payload;
|
|
328
|
+
}
|
|
329
|
+
await syncSessions(stateStore, relay);
|
|
330
|
+
return { ...summarizeState(stateStore.read()), action };
|
|
331
|
+
}
|
|
332
|
+
async function readRawBody(request) {
|
|
333
|
+
const chunks = [];
|
|
334
|
+
for await (const chunk of request) {
|
|
335
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
336
|
+
}
|
|
337
|
+
return Buffer.concat(chunks);
|
|
338
|
+
}
|
|
339
|
+
function parseJsonBody(body) {
|
|
340
|
+
if (!body) {
|
|
341
|
+
return {};
|
|
342
|
+
}
|
|
343
|
+
const text = Buffer.isBuffer(body) ? body.toString("utf8") : body;
|
|
344
|
+
return text ? JSON.parse(text) : {};
|
|
345
|
+
}
|
|
346
|
+
function stringValue(value) {
|
|
347
|
+
return typeof value === "string" ? value : "";
|
|
348
|
+
}
|
|
349
|
+
function stringOrUndefined(value) {
|
|
350
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
351
|
+
}
|
|
352
|
+
function jsonResponse(statusCode, body) {
|
|
353
|
+
return {
|
|
354
|
+
statusCode,
|
|
355
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
356
|
+
body: JSON.stringify(body)
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
function htmlResponse(body) {
|
|
360
|
+
return {
|
|
361
|
+
statusCode: 200,
|
|
362
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
363
|
+
body
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function writeResponse(response, result) {
|
|
367
|
+
response.writeHead(result.statusCode, result.headers ?? {});
|
|
368
|
+
response.end(result.body);
|
|
369
|
+
}
|