clawsocial-plugin 1.4.0 → 1.6.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.
@@ -0,0 +1,525 @@
1
+ import http from "node:http";
2
+ import { getSessions, addMessage, markRead } from "./store.js";
3
+ import api from "./api.js";
4
+ import { t, getLang, formatTime, formatDateTime } from "./i18n.js";
5
+
6
+ let _server: http.Server | null = null;
7
+ let _port: number | null = null;
8
+
9
+ // ── Port finder ──────────────────────────────────────────────────────
10
+
11
+ function findAvailablePort(start: number): Promise<number> {
12
+ return new Promise((resolve, reject) => {
13
+ const probe = http.createServer();
14
+ probe.listen(start, "127.0.0.1", () => {
15
+ const port = (probe.address() as { port: number }).port;
16
+ probe.close(() => resolve(port));
17
+ });
18
+ probe.on("error", () =>
19
+ findAvailablePort(start + 1).then(resolve).catch(reject),
20
+ );
21
+ });
22
+ }
23
+
24
+ // ── HTML helpers ─────────────────────────────────────────────────────
25
+
26
+ function esc(s: string): string {
27
+ return String(s)
28
+ .replace(/&/g, "&amp;")
29
+ .replace(/</g, "&lt;")
30
+ .replace(/>/g, "&gt;")
31
+ .replace(/"/g, "&quot;");
32
+ }
33
+
34
+ function escJs(s: string): string {
35
+ return String(s)
36
+ .replace(/\\/g, "\\\\")
37
+ .replace(/'/g, "\\'")
38
+ .replace(/</g, "\\x3c")
39
+ .replace(/\n/g, "\\n")
40
+ .replace(/\r/g, "\\r");
41
+ }
42
+
43
+ function escContent(s: string): string {
44
+ return esc(s).replace(/\n/g, "<br>");
45
+ }
46
+
47
+ function htmlLang(): string {
48
+ return getLang() === "zh" ? "zh-CN" : "en";
49
+ }
50
+
51
+ const SHARED_CSS = `
52
+ * { box-sizing: border-box; margin: 0; padding: 0; }
53
+ :root {
54
+ --bg: #0f0f13; --surface: #1a1a22; --surface2: #22222e;
55
+ --border: #2e2e3e; --text: #f0f0f5; --text-muted: #7a7a9a;
56
+ --accent: #7c6af7; --accent-light: #9d8ff9;
57
+ --green: #30d158; --red: #ff453a; --unread: #7c6af7;
58
+ --bubble-self: #7c6af7; --bubble-other: #22222e;
59
+ }
60
+ body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', sans-serif; background: var(--bg); color: var(--text); }
61
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
62
+ `;
63
+
64
+ // ── Sessions list page ───────────────────────────────────────────────
65
+
66
+ function renderSessions(): string {
67
+ const sessions = getSessions();
68
+ const list = Object.values(sessions).sort(
69
+ (a, b) => (b.last_active_at ?? 0) - (a.last_active_at ?? 0),
70
+ );
71
+ const totalUnread = list.reduce((s, x) => s + (x.unread ?? 0), 0);
72
+
73
+ const cards = list.length === 0
74
+ ? `<div class="empty">
75
+ <div class="empty-icon">🦞</div>
76
+ <h2>${t("local_no_sessions")}</h2>
77
+ <p>${t("local_no_sessions_p")}</p>
78
+ </div>`
79
+ : list.map((s) => {
80
+ const name = esc(s.partner_name ?? s.partner_agent_id ?? t("local_unknown"));
81
+ const avatarChar = (s.partner_name ?? s.partner_agent_id ?? "?")[0].toUpperCase();
82
+ const preview = esc((s.last_message ?? t("local_no_msg")).slice(0, 60));
83
+ const unreadBadge = (s.unread ?? 0) > 0
84
+ ? `<span class="unread-badge">${s.unread}</span>` : "";
85
+ const statusClass = s.status === "active" ? "status-active" : "status-pending";
86
+ const statusLabel = s.status === "active" ? t("local_active") : s.status === "pending" ? t("local_pending") : s.status;
87
+ const time = s.last_active_at
88
+ ? formatDateTime(s.last_active_at) : "";
89
+ return `
90
+ <a class="session-card${(s.unread ?? 0) > 0 ? " has-unread" : ""}" href="/session/${esc(s.id)}">
91
+ <div class="avatar">${esc(avatarChar)}</div>
92
+ <div class="card-body">
93
+ <div class="card-top">
94
+ <span class="partner-name">${name}</span>
95
+ <span class="card-time">${time}</span>
96
+ </div>
97
+ <div class="last-msg">${preview}</div>
98
+ <div class="card-bottom">
99
+ <span class="status-pill ${statusClass}">${statusLabel}</span>
100
+ ${unreadBadge}
101
+ </div>
102
+ </div>
103
+ </a>`;
104
+ }).join("\n");
105
+
106
+ return `<!DOCTYPE html>
107
+ <html lang="${htmlLang()}">
108
+ <head>
109
+ <meta charset="UTF-8">
110
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
111
+ <title>${t("local_title")}</title>
112
+ <style>
113
+ ${SHARED_CSS}
114
+ header {
115
+ background: var(--surface); border-bottom: 1px solid var(--border);
116
+ padding: 0 24px; height: 60px;
117
+ display: flex; align-items: center; gap: 12px;
118
+ position: sticky; top: 0; z-index: 10;
119
+ }
120
+ .logo { font-size: 22px; }
121
+ header h1 { font-size: 17px; font-weight: 600; flex: 1; }
122
+ .badge { background: var(--accent); color: #fff; border-radius: 20px; padding: 3px 10px; font-size: 12px; font-weight: 700; }
123
+ .local-tag { background: rgba(48,209,88,.15); color: var(--green); border-radius: 8px; padding: 3px 10px; font-size: 12px; font-weight: 500; }
124
+ .home-link { color: var(--text-muted); text-decoration: none; font-size: 13px; padding: 5px 10px; border-radius: 8px; transition: background 0.15s, color 0.15s; }
125
+ .home-link:hover { background: var(--surface2); color: var(--accent-light); }
126
+ .container { max-width: 680px; margin: 0 auto; padding: 24px 16px; }
127
+ .session-list { display: flex; flex-direction: column; gap: 8px; }
128
+ .session-card {
129
+ background: var(--surface); border: 1px solid var(--border);
130
+ border-radius: 16px; padding: 16px 18px; cursor: pointer;
131
+ transition: background 0.15s, border-color 0.15s, transform 0.1s;
132
+ text-decoration: none; color: inherit;
133
+ display: flex; align-items: center; gap: 14px;
134
+ }
135
+ .session-card:hover { background: var(--surface2); border-color: var(--accent); transform: translateY(-1px); }
136
+ .session-card.has-unread { border-left: 3px solid var(--unread); }
137
+ .avatar {
138
+ width: 46px; height: 46px; border-radius: 50%;
139
+ background: linear-gradient(135deg, var(--accent), #a78bfa);
140
+ display: flex; align-items: center; justify-content: center;
141
+ font-size: 18px; font-weight: 700; color: #fff; flex-shrink: 0;
142
+ }
143
+ .card-body { flex: 1; min-width: 0; }
144
+ .card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
145
+ .partner-name { font-size: 15px; font-weight: 600; }
146
+ .card-time { font-size: 12px; color: var(--text-muted); }
147
+ .last-msg { font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
148
+ .card-bottom { display: flex; justify-content: space-between; align-items: center; margin-top: 6px; }
149
+ .status-pill { font-size: 11px; padding: 2px 8px; border-radius: 8px; font-weight: 500; }
150
+ .status-active { background: rgba(48,209,88,.15); color: var(--green); }
151
+ .status-pending { background: rgba(255,214,10,.12); color: #ffd60a; }
152
+ .unread-badge { background: var(--accent); color: #fff; border-radius: 12px; padding: 2px 8px; font-size: 11px; font-weight: 700; }
153
+ .empty { text-align: center; padding: 80px 24px; color: var(--text-muted); }
154
+ .empty-icon { font-size: 52px; margin-bottom: 16px; }
155
+ .empty h2 { font-size: 18px; font-weight: 600; color: var(--text); margin-bottom: 8px; }
156
+ .empty p { font-size: 14px; line-height: 1.6; }
157
+ </style>
158
+ </head>
159
+ <body>
160
+ <header>
161
+ <span class="logo">🦞</span>
162
+ <h1>ClawSocial</h1>
163
+ ${totalUnread > 0 ? `<span class="badge">${totalUnread}</span>` : ""}
164
+ <span class="local-tag">${t("local_tag")}</span>
165
+ <a class="home-link" href="https://claw-social.com" target="_blank">${t("local_home")}</a>
166
+ </header>
167
+ <div class="container">
168
+ <div class="session-list">${cards}</div>
169
+ </div>
170
+ <script>
171
+ setTimeout(() => location.reload(), 10000);
172
+ </script>
173
+ </body>
174
+ </html>`;
175
+ }
176
+
177
+ // ── Session detail page ──────────────────────────────────────────────
178
+
179
+ function renderSession(sessionId: string): string | null {
180
+ const sessions = getSessions();
181
+ const session = sessions[sessionId];
182
+ if (!session) return null;
183
+
184
+ markRead(sessionId);
185
+
186
+ const partnerName = esc(session.partner_name ?? session.partner_agent_id ?? t("local_unknown"));
187
+ const avatarChar = esc(
188
+ (session.partner_name ?? session.partner_agent_id ?? "?")[0].toUpperCase(),
189
+ );
190
+ const isActive = session.status === "active";
191
+ const statusClass = isActive ? "status-active" : "status-pending";
192
+ const statusLabel = isActive ? t("local_active") : session.status === "pending" ? t("local_pending") : esc(session.status);
193
+ const totalCount = (session.messages ?? []).length;
194
+
195
+ const msgHtml = (session.messages ?? []).length === 0
196
+ ? `<div class="empty-state"><div class="icon">💬</div><p>${t("local_no_messages")}</p></div>`
197
+ : (session.messages ?? []).map((m) => {
198
+ const time = m.created_at
199
+ ? formatTime(m.created_at)
200
+ : "";
201
+ const side = m.from_self ? "msg-self" : "msg-other";
202
+ const avatarEl = m.from_self ? "" : `<div class="msg-avatar">${avatarChar}</div>`;
203
+ return `
204
+ <div class="msg ${side}" data-id="${esc(m.id)}">
205
+ <div class="msg-row">${avatarEl}<div class="bubble">${escContent(m.content)}</div></div>
206
+ <div class="msg-meta">${time}</div>
207
+ </div>`;
208
+ }).join("\n");
209
+
210
+ const replyBar = isActive ? `
211
+ <div class="reply-bar">
212
+ <textarea id="replyInput" placeholder="${t("local_placeholder")}" rows="1"></textarea>
213
+ <button class="send-btn" id="sendBtn">↑</button>
214
+ </div>` : "";
215
+
216
+ // Pre-compute i18n strings for client-side JS
217
+ const clientLocale = getLang() === "zh" ? "zh-CN" : "en-US";
218
+ const clientSendFail = escJs(t("local_send_fail"));
219
+ const clientUnknownErr = escJs(t("local_unknown_err"));
220
+
221
+ return `<!DOCTYPE html>
222
+ <html lang="${htmlLang()}">
223
+ <head>
224
+ <meta charset="UTF-8">
225
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
226
+ <title>${partnerName} — ClawSocial</title>
227
+ <style>
228
+ ${SHARED_CSS}
229
+ body { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
230
+ header {
231
+ background: var(--surface); border-bottom: 1px solid var(--border);
232
+ padding: 0 16px; height: 60px;
233
+ display: flex; align-items: center; gap: 12px; flex-shrink: 0;
234
+ }
235
+ .back-btn { color: var(--accent-light); text-decoration: none; font-size: 13px; display: flex; align-items: center; gap: 4px; padding: 6px 10px; border-radius: 8px; transition: background 0.15s; }
236
+ .back-btn:hover { background: var(--surface2); }
237
+ .home-link { color: var(--text-muted); text-decoration: none; font-size: 13px; padding: 5px 10px; border-radius: 8px; transition: background 0.15s, color 0.15s; }
238
+ .home-link:hover { background: var(--surface2); color: var(--accent-light); }
239
+ .header-avatar {
240
+ width: 36px; height: 36px; border-radius: 50%;
241
+ background: linear-gradient(135deg, var(--accent), #a78bfa);
242
+ display: flex; align-items: center; justify-content: center;
243
+ font-size: 14px; font-weight: 700; color: #fff; flex-shrink: 0;
244
+ }
245
+ .header-info { flex: 1; min-width: 0; }
246
+ .header-name { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
247
+ .header-sub { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 5px; margin-top: 1px; }
248
+ .status-pill { font-size: 11px; padding: 2px 7px; border-radius: 6px; font-weight: 500; }
249
+ .status-active { background: rgba(48,209,88,.15); color: var(--green); }
250
+ .status-pending { background: rgba(255,214,10,.12); color: #ffd60a; }
251
+ .local-tag { background: rgba(48,209,88,.15); color: var(--green); border-radius: 8px; padding: 3px 10px; font-size: 12px; font-weight: 500; }
252
+ .msg-count { font-size: 12px; color: var(--text-muted); }
253
+ .messages {
254
+ flex: 1; overflow-y: auto; padding: 16px;
255
+ display: flex; flex-direction: column; gap: 4px; scroll-behavior: smooth;
256
+ }
257
+ .messages::-webkit-scrollbar { width: 4px; }
258
+ .messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
259
+ .msg { max-width: 75%; margin-bottom: 2px; }
260
+ .msg-self { align-self: flex-end; }
261
+ .msg-other { align-self: flex-start; }
262
+ .msg-row { display: flex; align-items: flex-end; gap: 8px; }
263
+ .msg-self .msg-row { flex-direction: row-reverse; }
264
+ .msg-avatar {
265
+ width: 28px; height: 28px; border-radius: 50%;
266
+ background: linear-gradient(135deg, var(--accent), #a78bfa);
267
+ display: flex; align-items: center; justify-content: center;
268
+ font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0;
269
+ }
270
+ .bubble {
271
+ padding: 10px 14px; border-radius: 18px;
272
+ line-height: 1.55; font-size: 14px; white-space: pre-wrap; word-break: break-word;
273
+ }
274
+ .msg-self .bubble { background: var(--bubble-self); color: #fff; border-bottom-right-radius: 5px; }
275
+ .msg-other .bubble { background: var(--bubble-other); color: var(--text); border-bottom-left-radius: 5px; border: 1px solid var(--border); }
276
+ .msg-meta { font-size: 11px; color: var(--text-muted); margin-top: 3px; padding: 0 6px; opacity: 0; transition: opacity 0.15s; }
277
+ .msg:hover .msg-meta { opacity: 1; }
278
+ .msg-self .msg-meta { text-align: right; }
279
+ .empty-state { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; color: var(--text-muted); }
280
+ .empty-state .icon { font-size: 40px; }
281
+ .reply-bar {
282
+ background: var(--surface); border-top: 1px solid var(--border);
283
+ padding: 12px 16px; display: flex; align-items: flex-end; gap: 10px; flex-shrink: 0;
284
+ }
285
+ .reply-bar textarea {
286
+ flex: 1; background: var(--surface2); color: var(--text);
287
+ border: 1px solid var(--border); border-radius: 20px;
288
+ padding: 10px 16px; font-size: 14px; resize: none; font-family: inherit;
289
+ line-height: 1.5; max-height: 120px; transition: border-color 0.15s;
290
+ }
291
+ .reply-bar textarea::placeholder { color: var(--text-muted); }
292
+ .reply-bar textarea:focus { outline: none; border-color: var(--accent); }
293
+ .send-btn {
294
+ background: var(--accent); color: #fff; border: none; border-radius: 50%;
295
+ width: 40px; height: 40px; cursor: pointer; font-size: 16px;
296
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0;
297
+ transition: opacity 0.15s, transform 0.1s;
298
+ }
299
+ .send-btn:hover { opacity: 0.85; transform: scale(1.05); }
300
+ .send-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
301
+ @media (max-width: 480px) { .msg { max-width: 88%; } }
302
+ </style>
303
+ </head>
304
+ <body>
305
+ <header>
306
+ <a class="back-btn" href="/">${t("local_back")}</a>
307
+ <div class="header-avatar">${avatarChar}</div>
308
+ <div class="header-info">
309
+ <div class="header-name">${partnerName}</div>
310
+ <div class="header-sub">
311
+ <span class="status-pill ${statusClass}">${statusLabel}</span>
312
+ <span class="msg-count">${t("local_msg_count", { n: totalCount })}</span>
313
+ </div>
314
+ </div>
315
+ <span class="local-tag">${t("local_tag")}</span>
316
+ <a class="home-link" href="https://claw-social.com" target="_blank">${t("local_home")}</a>
317
+ </header>
318
+
319
+ <div class="messages" id="messages">
320
+ ${msgHtml}
321
+ </div>
322
+
323
+ ${replyBar}
324
+
325
+ <script>
326
+ const SESSION_ID = '${escJs(sessionId)}';
327
+ const AVATAR_CHAR = '${escJs(avatarChar)}';
328
+ const IS_ACTIVE = ${isActive ? "true" : "false"};
329
+ const CLIENT_LOCALE = '${clientLocale}';
330
+ const SEND_FAIL = '${clientSendFail}';
331
+ const UNKNOWN_ERR = '${clientUnknownErr}';
332
+ const msgs = document.getElementById('messages');
333
+
334
+ function scrollBottom() { msgs.scrollTop = msgs.scrollHeight; }
335
+ scrollBottom();
336
+
337
+ function escHtml(s) {
338
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>');
339
+ }
340
+
341
+ function appendMessage(m) {
342
+ const empty = msgs.querySelector('.empty-state');
343
+ if (empty) empty.remove();
344
+ const isSelf = m.from_self === true || m.from_self === 'true';
345
+ const t = m.created_at ? new Date(m.created_at * 1000).toLocaleTimeString(CLIENT_LOCALE, {hour:'2-digit',minute:'2-digit'}) : '';
346
+ const div = document.createElement('div');
347
+ div.className = 'msg ' + (isSelf ? 'msg-self' : 'msg-other');
348
+ div.setAttribute('data-id', m.id || '');
349
+ div.innerHTML =
350
+ '<div class="msg-row">' +
351
+ (!isSelf ? '<div class="msg-avatar">' + escHtml(AVATAR_CHAR) + '</div>' : '') +
352
+ '<div class="bubble">' + escHtml(m.content) + '</div></div>' +
353
+ '<div class="msg-meta">' + t + '</div>';
354
+ msgs.appendChild(div);
355
+ }
356
+
357
+ let lastMsgId = msgs.lastElementChild?.getAttribute('data-id') || '';
358
+ setInterval(async () => {
359
+ try {
360
+ const res = await fetch('/session/' + SESSION_ID + '/messages?after=' + encodeURIComponent(lastMsgId));
361
+ if (!res.ok) return;
362
+ const newMsgs = await res.json();
363
+ if (newMsgs.length > 0) {
364
+ const wasAtBottom = msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 60;
365
+ newMsgs.forEach(m => { appendMessage(m); lastMsgId = m.id || lastMsgId; });
366
+ if (wasAtBottom) scrollBottom();
367
+ }
368
+ } catch {}
369
+ }, 5000);
370
+
371
+ ${isActive ? `
372
+ const inp = document.getElementById('replyInput');
373
+ const btn = document.getElementById('sendBtn');
374
+ inp.addEventListener('input', () => {
375
+ inp.style.height = 'auto';
376
+ inp.style.height = Math.min(inp.scrollHeight, 120) + 'px';
377
+ });
378
+ inp.addEventListener('keydown', e => {
379
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendReply(); }
380
+ });
381
+ btn.addEventListener('click', sendReply);
382
+
383
+ async function sendReply() {
384
+ const content = inp.value.trim();
385
+ if (!content) return;
386
+ btn.disabled = true;
387
+ inp.value = '';
388
+ inp.style.height = 'auto';
389
+ try {
390
+ const res = await fetch('/session/' + SESSION_ID + '/reply', {
391
+ method: 'POST',
392
+ headers: { 'Content-Type': 'application/json' },
393
+ body: JSON.stringify({ content }),
394
+ });
395
+ const data = await res.json();
396
+ if (data.ok) {
397
+ appendMessage({ from_self: true, content, created_at: Math.floor(Date.now()/1000), id: 'local-' + Date.now() });
398
+ scrollBottom();
399
+ } else {
400
+ alert(SEND_FAIL + '\\uff1a' + (data.error || UNKNOWN_ERR));
401
+ inp.value = content;
402
+ }
403
+ } catch (err) {
404
+ alert(SEND_FAIL + '\\uff1a' + err.message);
405
+ inp.value = content;
406
+ } finally {
407
+ btn.disabled = false;
408
+ inp.focus();
409
+ }
410
+ }` : ""}
411
+ </script>
412
+ </body>
413
+ </html>`;
414
+ }
415
+
416
+ // ── Request handler ──────────────────────────────────────────────────
417
+
418
+ async function handleRequest(
419
+ req: http.IncomingMessage,
420
+ res: http.ServerResponse,
421
+ ): Promise<void> {
422
+ const url = new URL(req.url ?? "/", "http://localhost");
423
+ const pathname = url.pathname;
424
+
425
+ // GET / — sessions list
426
+ if (req.method === "GET" && pathname === "/") {
427
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
428
+ res.end(renderSessions());
429
+ return;
430
+ }
431
+
432
+ // GET /session/:id — session detail
433
+ const sessionMatch = pathname.match(/^\/session\/([^/]+)$/);
434
+ if (req.method === "GET" && sessionMatch) {
435
+ const html = renderSession(sessionMatch[1]);
436
+ if (!html) { res.writeHead(404); res.end("Not found"); return; }
437
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
438
+ res.end(html);
439
+ return;
440
+ }
441
+
442
+ // GET /session/:id/messages?after=<lastId> — polling new messages
443
+ const msgsMatch = pathname.match(/^\/session\/([^/]+)\/messages$/);
444
+ if (req.method === "GET" && msgsMatch) {
445
+ const session = getSessions()[msgsMatch[1]];
446
+ if (!session) { res.writeHead(404); res.end("[]"); return; }
447
+ const afterId = url.searchParams.get("after") ?? "";
448
+ const msgs = session.messages ?? [];
449
+ const idx = afterId ? msgs.findIndex((m) => m.id === afterId) : -1;
450
+ const newMsgs = idx >= 0 ? msgs.slice(idx + 1) : [];
451
+ res.writeHead(200, { "Content-Type": "application/json" });
452
+ res.end(JSON.stringify(newMsgs));
453
+ return;
454
+ }
455
+
456
+ // POST /session/:id/reply — send message
457
+ const replyMatch = pathname.match(/^\/session\/([^/]+)\/reply$/);
458
+ if (req.method === "POST" && replyMatch) {
459
+ const origin = req.headers.origin ?? "";
460
+ if (origin && !/^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin)) {
461
+ res.writeHead(403, { "Content-Type": "application/json" });
462
+ res.end(JSON.stringify({ error: "Origin not allowed" }));
463
+ return;
464
+ }
465
+ const sessionId = replyMatch[1];
466
+ let body = "";
467
+ req.on("data", (chunk: Buffer) => (body += chunk.toString()));
468
+ req.on("end", async () => {
469
+ try {
470
+ const { content } = JSON.parse(body) as { content: string };
471
+ if (!content?.trim()) {
472
+ res.writeHead(400, { "Content-Type": "application/json" });
473
+ res.end(JSON.stringify({ error: "content required" }));
474
+ return;
475
+ }
476
+ await api.sendMessage(sessionId, { content, intent: "chat" });
477
+ // Also store in local sessions.json
478
+ const session = getSessions()[sessionId];
479
+ addMessage(sessionId, {
480
+ id: `local-${Date.now()}`,
481
+ from_self: true,
482
+ content,
483
+ intent: "chat",
484
+ created_at: Math.floor(Date.now() / 1000),
485
+ partner_name: session?.partner_name,
486
+ });
487
+ res.writeHead(200, { "Content-Type": "application/json" });
488
+ res.end(JSON.stringify({ ok: true }));
489
+ } catch (err: unknown) {
490
+ res.writeHead(500, { "Content-Type": "application/json" });
491
+ res.end(JSON.stringify({ error: (err as Error).message }));
492
+ }
493
+ });
494
+ return;
495
+ }
496
+
497
+ res.writeHead(404);
498
+ res.end("Not found");
499
+ }
500
+
501
+ // ── Public API ───────────────────────────────────────────────────────
502
+
503
+ export async function startLocalServer(): Promise<string> {
504
+ if (_server && _port) {
505
+ return `http://localhost:${_port}`;
506
+ }
507
+ const port = await findAvailablePort(7747);
508
+ _server = http.createServer((req, res) => {
509
+ handleRequest(req, res).catch((err: unknown) => {
510
+ console.error("[ClawSocial LocalServer]", err);
511
+ if (!res.headersSent) {
512
+ res.writeHead(500);
513
+ res.end("Internal error");
514
+ }
515
+ });
516
+ });
517
+ await new Promise<void>((resolve) => _server!.listen(port, "127.0.0.1", resolve));
518
+ _port = port;
519
+ console.log(`[ClawSocial] ${t("local_started")}: http://localhost:${port}`);
520
+ return `http://localhost:${port}`;
521
+ }
522
+
523
+ export function getLocalServerUrl(): string | null {
524
+ return _port ? `http://localhost:${_port}` : null;
525
+ }
package/src/store.ts CHANGED
@@ -23,6 +23,9 @@ function stateFile(): string {
23
23
  return path.join(getDataDir(), "state.json");
24
24
  }
25
25
 
26
+ // NOTE: All file I/O is synchronous, which is safe in single-threaded Node.js
27
+ // (no concurrent read-modify-write races). If migrating to async I/O in the
28
+ // future, add a mutex/lock around read-modify-write sequences.
26
29
  function readJSON<T>(file: string, fallback: T): T {
27
30
  try {
28
31
  return JSON.parse(fs.readFileSync(file, "utf8")) as T;
@@ -68,7 +71,7 @@ type SessionsMap = Record<string, LocalSession>;
68
71
  export type NotifyMode = "silent" | "minimal" | "detail";
69
72
  export type Settings = { notifyMode: NotifyMode };
70
73
 
71
- const DEFAULT_SETTINGS: Settings = { notifyMode: "minimal" };
74
+ const DEFAULT_SETTINGS: Settings = { notifyMode: "silent" };
72
75
 
73
76
  function settingsFile(): string {
74
77
  return path.join(getDataDir(), "settings.json");
@@ -91,6 +94,7 @@ export type AgentState = {
91
94
  token?: string;
92
95
  public_name?: string;
93
96
  registered_at?: number;
97
+ lang?: string;
94
98
  };
95
99
 
96
100
  // ── Sessions ────────────────────────────────────────────────────────
@@ -118,7 +122,9 @@ export function addMessage(sessionId: string, msg: LocalMessage): void {
118
122
  sessions[sessionId].messages.push(msg);
119
123
  sessions[sessionId].last_message = msg.content;
120
124
  sessions[sessionId].last_active_at = msg.created_at;
121
- sessions[sessionId].unread = (sessions[sessionId].unread ?? 0) + 1;
125
+ if (!msg.from_self) {
126
+ sessions[sessionId].unread = (sessions[sessionId].unread ?? 0) + 1;
127
+ }
122
128
  sessions[sessionId].updated_at = Math.floor(Date.now() / 1000);
123
129
  writeJSON(sessionsFile(), sessions);
124
130
  }
@@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox";
2
2
  import type { AnyAgentTool } from "../types.js";
3
3
  import api from "../api.js";
4
4
  import { getSessions, upsertSession } from "../store.js";
5
+ import { t } from "../i18n.js";
5
6
 
6
7
  export function createBlockTool(): AnyAgentTool {
7
8
  return {
@@ -10,9 +11,9 @@ export function createBlockTool(): AnyAgentTool {
10
11
  description:
11
12
  "Block an agent. They will no longer be able to contact you, and any existing session is closed. Call when the user explicitly says they don't want to hear from someone.",
12
13
  parameters: Type.Object({
13
- agent_id: Type.Optional(Type.String({ description: "精确 agent ID(与 partner_name 二选一)" })),
14
+ agent_id: Type.Optional(Type.String({ description: "Exact agent ID (provide either this or partner_name)" })),
14
15
  partner_name: Type.Optional(
15
- Type.String({ description: "按名称模糊匹配(与 agent_id 二选一)" }),
16
+ Type.String({ description: "Fuzzy match by name (provide either this or agent_id)" }),
16
17
  ),
17
18
  }),
18
19
  async execute(_id: string, params: Record<string, unknown>) {
@@ -28,7 +29,7 @@ export function createBlockTool(): AnyAgentTool {
28
29
  if (match) agentId = match.partner_agent_id;
29
30
  }
30
31
 
31
- if (!agentId) throw new Error("agent_id partner_name 不能为空");
32
+ if (!agentId) throw new Error("agent_id or partner_name is required");
32
33
 
33
34
  const res = await api.blockAgent(agentId);
34
35
 
@@ -42,7 +43,7 @@ export function createBlockTool(): AnyAgentTool {
42
43
  const result = {
43
44
  ok: true,
44
45
  sessions_closed: res.sessions_closed ?? 0,
45
- message: "✅ 已屏蔽,对方将无法再联系你",
46
+ message: t("tools_blocked"),
46
47
  };
47
48
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
48
49
  },
@@ -10,13 +10,13 @@ export function createConnectTool(serverUrl: string): AnyAgentTool {
10
10
  description:
11
11
  "Send a connection request to a candidate. Call AFTER clawsocial_find or clawsocial_match, ONLY with explicit user approval. NEVER call without the user agreeing.",
12
12
  parameters: Type.Object({
13
- target_agent_id: Type.String({ description: "来自搜索结果的 agent_id" }),
14
- target_name: Type.Optional(Type.String({ description: "对方的 public_name" })),
15
- target_topic_tags: Type.Optional(Type.Array(Type.String(), { description: "对方的 topic_tags" })),
16
- target_auto_bio: Type.Optional(Type.String({ description: "对方的 auto_bio" })),
13
+ target_agent_id: Type.String({ description: "agent_id from search results" }),
14
+ target_name: Type.Optional(Type.String({ description: "Partner's public_name" })),
15
+ target_topic_tags: Type.Optional(Type.Array(Type.String(), { description: "Partner's topic_tags" })),
16
+ target_auto_bio: Type.Optional(Type.String({ description: "Partner's auto_bio" })),
17
17
  intro_message: Type.String({
18
18
  description:
19
- "传入用户本次搜索意图原文。不要包含真实姓名、联系方式或位置。",
19
+ "User's original search intent. Do not include real names, contact info, or locations.",
20
20
  }),
21
21
  }),
22
22
  async execute(_id: string, params: Record<string, unknown>) {
@@ -25,8 +25,8 @@ export function createConnectTool(serverUrl: string): AnyAgentTool {
25
25
  const target_topic_tags = params.target_topic_tags as string[] | undefined;
26
26
  const target_auto_bio = params.target_auto_bio as string | undefined;
27
27
  const intro_message = params.intro_message as string;
28
- if (!target_agent_id) throw new Error("target_agent_id 不能为空");
29
- if (!intro_message) throw new Error("intro_message 不能为空,需要简短说明连接原因");
28
+ if (!target_agent_id) throw new Error("target_agent_id is required");
29
+ if (!intro_message) throw new Error("intro_message is required — briefly explain the reason for connecting");
30
30
 
31
31
  const res = await api.connect({ target_agent_id, intro_message });
32
32