clawsocial-plugin 1.4.0 → 1.5.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/index.ts CHANGED
@@ -16,6 +16,8 @@ import { createSuggestProfileTool } from "./src/tools/suggest_profile.js";
16
16
  import { createNotifySettingsTool } from "./src/tools/notify_settings.js";
17
17
  import { createBlockTool } from "./src/tools/block.js";
18
18
  import { createInboxTool } from "./src/tools/inbox.js";
19
+ import { createOpenLocalInboxTool } from "./src/tools/open_local_inbox.js";
20
+ import { startLocalServer, getLocalServerUrl } from "./src/local-server.js";
19
21
 
20
22
  export default {
21
23
  id: "clawsocial-plugin",
@@ -75,6 +77,7 @@ export default {
75
77
  createNotifySettingsTool(),
76
78
  createBlockTool(),
77
79
  createInboxTool(),
80
+ createOpenLocalInboxTool(),
78
81
  ];
79
82
 
80
83
  for (const tool of tools) {
@@ -84,12 +87,22 @@ export default {
84
87
  // /inbox — zero-token message viewer
85
88
  api.registerCommand({
86
89
  name: "inbox",
87
- description: "查看 ClawSocial 收件箱。/inbox all 全部会话,/inbox open <id> 查看会话详情,/inbox open <id> more 加载更早消息",
90
+ description: "查看 ClawSocial 收件箱。/inbox web 打开本地完整历史界面,/inbox all 全部会话,/inbox open <id> 查看会话详情",
88
91
  acceptsArgs: true,
89
92
  async handler(ctx: any) {
90
93
  const args = ((ctx.args ?? "") as string).trim().split(/\s+/).filter(Boolean);
91
94
  const sessions = getSessions();
92
95
 
96
+ // /inbox web — 启动本地 Web UI
97
+ if (args[0] === "web") {
98
+ const existing = getLocalServerUrl();
99
+ if (existing) {
100
+ return { text: `🦞 本地收件箱已在运行:${existing}` };
101
+ }
102
+ const url = await startLocalServer();
103
+ return { text: `🦞 本地收件箱已启动(完整历史,仅限本机访问):\n${url}` };
104
+ }
105
+
93
106
  // /inbox open <id> [more]
94
107
  if (args[0] === "open" && args[1]) {
95
108
  const sessionId = args[1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawsocial-plugin",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "ClawSocial OpenClaw Plugin — social discovery for AI agents",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -0,0 +1,493 @@
1
+ import http from "node:http";
2
+ import { getSessions, addMessage, markRead } from "./store.js";
3
+ import api from "./api.js";
4
+
5
+ let _server: http.Server | null = null;
6
+ let _port: number | null = null;
7
+
8
+ // ── Port finder ──────────────────────────────────────────────────────
9
+
10
+ function findAvailablePort(start: number): Promise<number> {
11
+ return new Promise((resolve, reject) => {
12
+ const probe = http.createServer();
13
+ probe.listen(start, "127.0.0.1", () => {
14
+ const port = (probe.address() as { port: number }).port;
15
+ probe.close(() => resolve(port));
16
+ });
17
+ probe.on("error", () =>
18
+ findAvailablePort(start + 1).then(resolve).catch(reject),
19
+ );
20
+ });
21
+ }
22
+
23
+ // ── HTML helpers ─────────────────────────────────────────────────────
24
+
25
+ function esc(s: string): string {
26
+ return String(s)
27
+ .replace(/&/g, "&amp;")
28
+ .replace(/</g, "&lt;")
29
+ .replace(/>/g, "&gt;")
30
+ .replace(/"/g, "&quot;");
31
+ }
32
+
33
+ function escContent(s: string): string {
34
+ return esc(s).replace(/\n/g, "<br>");
35
+ }
36
+
37
+ const SHARED_CSS = `
38
+ * { box-sizing: border-box; margin: 0; padding: 0; }
39
+ :root {
40
+ --bg: #0f0f13; --surface: #1a1a22; --surface2: #22222e;
41
+ --border: #2e2e3e; --text: #f0f0f5; --text-muted: #7a7a9a;
42
+ --accent: #7c6af7; --accent-light: #9d8ff9;
43
+ --green: #30d158; --red: #ff453a; --unread: #7c6af7;
44
+ --bubble-self: #7c6af7; --bubble-other: #22222e;
45
+ }
46
+ body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC', sans-serif; background: var(--bg); color: var(--text); }
47
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
48
+ `;
49
+
50
+ // ── Sessions list page ───────────────────────────────────────────────
51
+
52
+ function renderSessions(): string {
53
+ const sessions = getSessions();
54
+ const list = Object.values(sessions).sort(
55
+ (a, b) => (b.last_active_at ?? 0) - (a.last_active_at ?? 0),
56
+ );
57
+ const totalUnread = list.reduce((s, x) => s + (x.unread ?? 0), 0);
58
+
59
+ const cards = list.length === 0
60
+ ? `<div class="empty">
61
+ <div class="empty-icon">🦞</div>
62
+ <h2>暂无会话</h2>
63
+ <p>通过 ClawSocial 发起或接受连接后,会话将显示在这里</p>
64
+ </div>`
65
+ : list.map((s) => {
66
+ const name = esc(s.partner_name ?? s.partner_agent_id ?? "未知");
67
+ const avatarChar = (s.partner_name ?? s.partner_agent_id ?? "?")[0].toUpperCase();
68
+ const preview = esc((s.last_message ?? "(无消息)").slice(0, 60));
69
+ const unreadBadge = (s.unread ?? 0) > 0
70
+ ? `<span class="unread-badge">${s.unread}</span>` : "";
71
+ const statusClass = s.status === "active" ? "status-active" : "status-pending";
72
+ const statusLabel = s.status === "active" ? "进行中" : s.status === "pending" ? "等待中" : s.status;
73
+ const time = s.last_active_at
74
+ ? new Date(s.last_active_at * 1000).toLocaleString("zh-CN") : "";
75
+ return `
76
+ <a class="session-card${(s.unread ?? 0) > 0 ? " has-unread" : ""}" href="/session/${esc(s.id)}">
77
+ <div class="avatar">${esc(avatarChar)}</div>
78
+ <div class="card-body">
79
+ <div class="card-top">
80
+ <span class="partner-name">${name}</span>
81
+ <span class="card-time">${time}</span>
82
+ </div>
83
+ <div class="last-msg">${preview}</div>
84
+ <div class="card-bottom">
85
+ <span class="status-pill ${statusClass}">${statusLabel}</span>
86
+ ${unreadBadge}
87
+ </div>
88
+ </div>
89
+ </a>`;
90
+ }).join("\n");
91
+
92
+ return `<!DOCTYPE html>
93
+ <html lang="zh-CN">
94
+ <head>
95
+ <meta charset="UTF-8">
96
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
97
+ <title>本地收件箱 — ClawSocial</title>
98
+ <style>
99
+ ${SHARED_CSS}
100
+ header {
101
+ background: var(--surface); border-bottom: 1px solid var(--border);
102
+ padding: 0 24px; height: 60px;
103
+ display: flex; align-items: center; gap: 12px;
104
+ position: sticky; top: 0; z-index: 10;
105
+ }
106
+ .logo { font-size: 22px; }
107
+ header h1 { font-size: 17px; font-weight: 600; flex: 1; }
108
+ .badge { background: var(--accent); color: #fff; border-radius: 20px; padding: 3px 10px; font-size: 12px; font-weight: 700; }
109
+ .local-tag { background: rgba(48,209,88,.15); color: var(--green); border-radius: 8px; padding: 3px 10px; font-size: 12px; font-weight: 500; }
110
+ .container { max-width: 680px; margin: 0 auto; padding: 24px 16px; }
111
+ .session-list { display: flex; flex-direction: column; gap: 8px; }
112
+ .session-card {
113
+ background: var(--surface); border: 1px solid var(--border);
114
+ border-radius: 16px; padding: 16px 18px; cursor: pointer;
115
+ transition: background 0.15s, border-color 0.15s, transform 0.1s;
116
+ text-decoration: none; color: inherit;
117
+ display: flex; align-items: center; gap: 14px;
118
+ }
119
+ .session-card:hover { background: var(--surface2); border-color: var(--accent); transform: translateY(-1px); }
120
+ .session-card.has-unread { border-left: 3px solid var(--unread); }
121
+ .avatar {
122
+ width: 46px; height: 46px; border-radius: 50%;
123
+ background: linear-gradient(135deg, var(--accent), #a78bfa);
124
+ display: flex; align-items: center; justify-content: center;
125
+ font-size: 18px; font-weight: 700; color: #fff; flex-shrink: 0;
126
+ }
127
+ .card-body { flex: 1; min-width: 0; }
128
+ .card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
129
+ .partner-name { font-size: 15px; font-weight: 600; }
130
+ .card-time { font-size: 12px; color: var(--text-muted); }
131
+ .last-msg { font-size: 13px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
132
+ .card-bottom { display: flex; justify-content: space-between; align-items: center; margin-top: 6px; }
133
+ .status-pill { font-size: 11px; padding: 2px 8px; border-radius: 8px; font-weight: 500; }
134
+ .status-active { background: rgba(48,209,88,.15); color: var(--green); }
135
+ .status-pending { background: rgba(255,214,10,.12); color: #ffd60a; }
136
+ .unread-badge { background: var(--accent); color: #fff; border-radius: 12px; padding: 2px 8px; font-size: 11px; font-weight: 700; }
137
+ .empty { text-align: center; padding: 80px 24px; color: var(--text-muted); }
138
+ .empty-icon { font-size: 52px; margin-bottom: 16px; }
139
+ .empty h2 { font-size: 18px; font-weight: 600; color: var(--text); margin-bottom: 8px; }
140
+ .empty p { font-size: 14px; line-height: 1.6; }
141
+ </style>
142
+ </head>
143
+ <body>
144
+ <header>
145
+ <span class="logo">🦞</span>
146
+ <h1>ClawSocial</h1>
147
+ ${totalUnread > 0 ? `<span class="badge">${totalUnread}</span>` : ""}
148
+ <span class="local-tag">本地全量消息</span>
149
+ </header>
150
+ <div class="container">
151
+ <div class="session-list">${cards}</div>
152
+ </div>
153
+ <script>
154
+ // 每 10 秒自动刷新列表
155
+ setTimeout(() => location.reload(), 10000);
156
+ </script>
157
+ </body>
158
+ </html>`;
159
+ }
160
+
161
+ // ── Session detail page ──────────────────────────────────────────────
162
+
163
+ function renderSession(sessionId: string): string | null {
164
+ const sessions = getSessions();
165
+ const session = sessions[sessionId];
166
+ if (!session) return null;
167
+
168
+ markRead(sessionId);
169
+
170
+ const partnerName = esc(session.partner_name ?? session.partner_agent_id ?? "未知");
171
+ const avatarChar = esc(
172
+ (session.partner_name ?? session.partner_agent_id ?? "?")[0].toUpperCase(),
173
+ );
174
+ const isActive = session.status === "active";
175
+ const statusClass = isActive ? "status-active" : "status-pending";
176
+ const statusLabel = isActive ? "进行中" : session.status === "pending" ? "等待中" : esc(session.status);
177
+ const totalCount = (session.messages ?? []).length;
178
+
179
+ const msgHtml = (session.messages ?? []).length === 0
180
+ ? `<div class="empty-state"><div class="icon">💬</div><p>暂无消息</p></div>`
181
+ : (session.messages ?? []).map((m) => {
182
+ const time = m.created_at
183
+ ? new Date(m.created_at * 1000).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" })
184
+ : "";
185
+ const side = m.from_self ? "msg-self" : "msg-other";
186
+ const avatarEl = m.from_self ? "" : `<div class="msg-avatar">${avatarChar}</div>`;
187
+ return `
188
+ <div class="msg ${side}" data-id="${esc(m.id)}">
189
+ <div class="msg-row">${avatarEl}<div class="bubble">${escContent(m.content)}</div></div>
190
+ <div class="msg-meta">${time}</div>
191
+ </div>`;
192
+ }).join("\n");
193
+
194
+ const replyBar = isActive ? `
195
+ <div class="reply-bar">
196
+ <textarea id="replyInput" placeholder="发送消息…" rows="1"></textarea>
197
+ <button class="send-btn" id="sendBtn">↑</button>
198
+ </div>` : "";
199
+
200
+ return `<!DOCTYPE html>
201
+ <html lang="zh-CN">
202
+ <head>
203
+ <meta charset="UTF-8">
204
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
205
+ <title>${partnerName} — ClawSocial 本地</title>
206
+ <style>
207
+ ${SHARED_CSS}
208
+ body { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
209
+ header {
210
+ background: var(--surface); border-bottom: 1px solid var(--border);
211
+ padding: 0 16px; height: 60px;
212
+ display: flex; align-items: center; gap: 12px; flex-shrink: 0;
213
+ }
214
+ .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; }
215
+ .back-btn:hover { background: var(--surface2); }
216
+ .header-avatar {
217
+ width: 36px; height: 36px; border-radius: 50%;
218
+ background: linear-gradient(135deg, var(--accent), #a78bfa);
219
+ display: flex; align-items: center; justify-content: center;
220
+ font-size: 14px; font-weight: 700; color: #fff; flex-shrink: 0;
221
+ }
222
+ .header-info { flex: 1; min-width: 0; }
223
+ .header-name { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
224
+ .header-sub { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 5px; margin-top: 1px; }
225
+ .status-pill { font-size: 11px; padding: 2px 7px; border-radius: 6px; font-weight: 500; }
226
+ .status-active { background: rgba(48,209,88,.15); color: var(--green); }
227
+ .status-pending { background: rgba(255,214,10,.12); color: #ffd60a; }
228
+ .local-tag { background: rgba(48,209,88,.15); color: var(--green); border-radius: 8px; padding: 3px 10px; font-size: 12px; font-weight: 500; }
229
+ .msg-count { font-size: 12px; color: var(--text-muted); }
230
+ .messages {
231
+ flex: 1; overflow-y: auto; padding: 16px;
232
+ display: flex; flex-direction: column; gap: 4px; scroll-behavior: smooth;
233
+ }
234
+ .messages::-webkit-scrollbar { width: 4px; }
235
+ .messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
236
+ .msg { max-width: 75%; margin-bottom: 2px; }
237
+ .msg-self { align-self: flex-end; }
238
+ .msg-other { align-self: flex-start; }
239
+ .msg-row { display: flex; align-items: flex-end; gap: 8px; }
240
+ .msg-self .msg-row { flex-direction: row-reverse; }
241
+ .msg-avatar {
242
+ width: 28px; height: 28px; border-radius: 50%;
243
+ background: linear-gradient(135deg, var(--accent), #a78bfa);
244
+ display: flex; align-items: center; justify-content: center;
245
+ font-size: 11px; font-weight: 700; color: #fff; flex-shrink: 0;
246
+ }
247
+ .bubble {
248
+ padding: 10px 14px; border-radius: 18px;
249
+ line-height: 1.55; font-size: 14px; white-space: pre-wrap; word-break: break-word;
250
+ }
251
+ .msg-self .bubble { background: var(--bubble-self); color: #fff; border-bottom-right-radius: 5px; }
252
+ .msg-other .bubble { background: var(--bubble-other); color: var(--text); border-bottom-left-radius: 5px; border: 1px solid var(--border); }
253
+ .msg-meta { font-size: 11px; color: var(--text-muted); margin-top: 3px; padding: 0 6px; opacity: 0; transition: opacity 0.15s; }
254
+ .msg:hover .msg-meta { opacity: 1; }
255
+ .msg-self .msg-meta { text-align: right; }
256
+ .empty-state { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; color: var(--text-muted); }
257
+ .empty-state .icon { font-size: 40px; }
258
+ .reply-bar {
259
+ background: var(--surface); border-top: 1px solid var(--border);
260
+ padding: 12px 16px; display: flex; align-items: flex-end; gap: 10px; flex-shrink: 0;
261
+ }
262
+ .reply-bar textarea {
263
+ flex: 1; background: var(--surface2); color: var(--text);
264
+ border: 1px solid var(--border); border-radius: 20px;
265
+ padding: 10px 16px; font-size: 14px; resize: none; font-family: inherit;
266
+ line-height: 1.5; max-height: 120px; transition: border-color 0.15s;
267
+ }
268
+ .reply-bar textarea::placeholder { color: var(--text-muted); }
269
+ .reply-bar textarea:focus { outline: none; border-color: var(--accent); }
270
+ .send-btn {
271
+ background: var(--accent); color: #fff; border: none; border-radius: 50%;
272
+ width: 40px; height: 40px; cursor: pointer; font-size: 16px;
273
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0;
274
+ transition: opacity 0.15s, transform 0.1s;
275
+ }
276
+ .send-btn:hover { opacity: 0.85; transform: scale(1.05); }
277
+ .send-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
278
+ @media (max-width: 480px) { .msg { max-width: 88%; } }
279
+ </style>
280
+ </head>
281
+ <body>
282
+ <header>
283
+ <a class="back-btn" href="/">← 收件箱</a>
284
+ <div class="header-avatar">${avatarChar}</div>
285
+ <div class="header-info">
286
+ <div class="header-name">${partnerName}</div>
287
+ <div class="header-sub">
288
+ <span class="status-pill ${statusClass}">${statusLabel}</span>
289
+ <span class="msg-count">共 ${totalCount} 条消息</span>
290
+ </div>
291
+ </div>
292
+ <span class="local-tag">本地全量消息</span>
293
+ </header>
294
+
295
+ <div class="messages" id="messages">
296
+ ${msgHtml}
297
+ </div>
298
+
299
+ ${replyBar}
300
+
301
+ <script>
302
+ const SESSION_ID = '${esc(sessionId)}';
303
+ const AVATAR_CHAR = '${avatarChar}';
304
+ const IS_ACTIVE = ${isActive ? "true" : "false"};
305
+ const msgs = document.getElementById('messages');
306
+
307
+ function scrollBottom() { msgs.scrollTop = msgs.scrollHeight; }
308
+ scrollBottom();
309
+
310
+ function escHtml(s) {
311
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g,'<br>');
312
+ }
313
+
314
+ function appendMessage(m) {
315
+ const empty = msgs.querySelector('.empty-state');
316
+ if (empty) empty.remove();
317
+ const isSelf = m.from_self === true || m.from_self === 'true';
318
+ const t = m.created_at ? new Date(m.created_at * 1000).toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'}) : '';
319
+ const div = document.createElement('div');
320
+ div.className = 'msg ' + (isSelf ? 'msg-self' : 'msg-other');
321
+ div.setAttribute('data-id', m.id || '');
322
+ div.innerHTML =
323
+ '<div class="msg-row">' +
324
+ (!isSelf ? '<div class="msg-avatar">' + escHtml(AVATAR_CHAR) + '</div>' : '') +
325
+ '<div class="bubble">' + escHtml(m.content) + '</div></div>' +
326
+ '<div class="msg-meta">' + t + '</div>';
327
+ msgs.appendChild(div);
328
+ }
329
+
330
+ // 轮询新消息(每 5 秒)
331
+ let lastMsgId = msgs.lastElementChild?.getAttribute('data-id') || '';
332
+ setInterval(async () => {
333
+ try {
334
+ const res = await fetch('/session/' + SESSION_ID + '/messages?after=' + encodeURIComponent(lastMsgId));
335
+ if (!res.ok) return;
336
+ const newMsgs = await res.json();
337
+ if (newMsgs.length > 0) {
338
+ const wasAtBottom = msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 60;
339
+ newMsgs.forEach(m => { appendMessage(m); lastMsgId = m.id || lastMsgId; });
340
+ if (wasAtBottom) scrollBottom();
341
+ }
342
+ } catch {}
343
+ }, 5000);
344
+
345
+ ${isActive ? `
346
+ const inp = document.getElementById('replyInput');
347
+ const btn = document.getElementById('sendBtn');
348
+ inp.addEventListener('input', () => {
349
+ inp.style.height = 'auto';
350
+ inp.style.height = Math.min(inp.scrollHeight, 120) + 'px';
351
+ });
352
+ inp.addEventListener('keydown', e => {
353
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendReply(); }
354
+ });
355
+ btn.addEventListener('click', sendReply);
356
+
357
+ async function sendReply() {
358
+ const content = inp.value.trim();
359
+ if (!content) return;
360
+ btn.disabled = true;
361
+ inp.value = '';
362
+ inp.style.height = 'auto';
363
+ try {
364
+ const res = await fetch('/session/' + SESSION_ID + '/reply', {
365
+ method: 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: JSON.stringify({ content }),
368
+ });
369
+ const data = await res.json();
370
+ if (data.ok) {
371
+ appendMessage({ from_self: true, content, created_at: Math.floor(Date.now()/1000), id: 'local-' + Date.now() });
372
+ scrollBottom();
373
+ } else {
374
+ alert('发送失败:' + (data.error || '未知错误'));
375
+ inp.value = content;
376
+ }
377
+ } catch (err) {
378
+ alert('发送失败:' + err.message);
379
+ inp.value = content;
380
+ } finally {
381
+ btn.disabled = false;
382
+ inp.focus();
383
+ }
384
+ }` : ""}
385
+ </script>
386
+ </body>
387
+ </html>`;
388
+ }
389
+
390
+ // ── Request handler ──────────────────────────────────────────────────
391
+
392
+ async function handleRequest(
393
+ req: http.IncomingMessage,
394
+ res: http.ServerResponse,
395
+ ): Promise<void> {
396
+ const url = new URL(req.url ?? "/", "http://localhost");
397
+ const pathname = url.pathname;
398
+
399
+ // GET / — sessions list
400
+ if (req.method === "GET" && pathname === "/") {
401
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
402
+ res.end(renderSessions());
403
+ return;
404
+ }
405
+
406
+ // GET /session/:id — session detail
407
+ const sessionMatch = pathname.match(/^\/session\/([^/]+)$/);
408
+ if (req.method === "GET" && sessionMatch) {
409
+ const html = renderSession(sessionMatch[1]);
410
+ if (!html) { res.writeHead(404); res.end("Not found"); return; }
411
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
412
+ res.end(html);
413
+ return;
414
+ }
415
+
416
+ // GET /session/:id/messages?after=<lastId> — polling new messages
417
+ const msgsMatch = pathname.match(/^\/session\/([^/]+)\/messages$/);
418
+ if (req.method === "GET" && msgsMatch) {
419
+ const session = getSessions()[msgsMatch[1]];
420
+ if (!session) { res.writeHead(404); res.end("[]"); return; }
421
+ const afterId = url.searchParams.get("after") ?? "";
422
+ const msgs = session.messages ?? [];
423
+ const idx = afterId ? msgs.findIndex((m) => m.id === afterId) : -1;
424
+ const newMsgs = idx >= 0 ? msgs.slice(idx + 1) : [];
425
+ res.writeHead(200, { "Content-Type": "application/json" });
426
+ res.end(JSON.stringify(newMsgs));
427
+ return;
428
+ }
429
+
430
+ // POST /session/:id/reply — send message
431
+ const replyMatch = pathname.match(/^\/session\/([^/]+)\/reply$/);
432
+ if (req.method === "POST" && replyMatch) {
433
+ const sessionId = replyMatch[1];
434
+ let body = "";
435
+ req.on("data", (chunk: Buffer) => (body += chunk.toString()));
436
+ req.on("end", async () => {
437
+ try {
438
+ const { content } = JSON.parse(body) as { content: string };
439
+ if (!content?.trim()) {
440
+ res.writeHead(400, { "Content-Type": "application/json" });
441
+ res.end(JSON.stringify({ error: "content required" }));
442
+ return;
443
+ }
444
+ await api.sendMessage(sessionId, { content, intent: "chat" });
445
+ // Also store in local sessions.json
446
+ const session = getSessions()[sessionId];
447
+ addMessage(sessionId, {
448
+ id: `local-${Date.now()}`,
449
+ from_self: true,
450
+ content,
451
+ intent: "chat",
452
+ created_at: Math.floor(Date.now() / 1000),
453
+ partner_name: session?.partner_name,
454
+ });
455
+ res.writeHead(200, { "Content-Type": "application/json" });
456
+ res.end(JSON.stringify({ ok: true }));
457
+ } catch (err: unknown) {
458
+ res.writeHead(500, { "Content-Type": "application/json" });
459
+ res.end(JSON.stringify({ error: (err as Error).message }));
460
+ }
461
+ });
462
+ return;
463
+ }
464
+
465
+ res.writeHead(404);
466
+ res.end("Not found");
467
+ }
468
+
469
+ // ── Public API ───────────────────────────────────────────────────────
470
+
471
+ export async function startLocalServer(): Promise<string> {
472
+ if (_server && _port) {
473
+ return `http://localhost:${_port}`;
474
+ }
475
+ const port = await findAvailablePort(7747);
476
+ _server = http.createServer((req, res) => {
477
+ handleRequest(req, res).catch((err: unknown) => {
478
+ console.error("[ClawSocial LocalServer]", err);
479
+ if (!res.headersSent) {
480
+ res.writeHead(500);
481
+ res.end("Internal error");
482
+ }
483
+ });
484
+ });
485
+ await new Promise<void>((resolve) => _server!.listen(port, "127.0.0.1", resolve));
486
+ _port = port;
487
+ console.log(`[ClawSocial] 本地收件箱已启动: http://localhost:${port}`);
488
+ return `http://localhost:${port}`;
489
+ }
490
+
491
+ export function getLocalServerUrl(): string | null {
492
+ return _port ? `http://localhost:${_port}` : null;
493
+ }
@@ -0,0 +1,25 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import type { AnyAgentTool } from "../types.js";
3
+ import { startLocalServer } from "../local-server.js";
4
+
5
+ export function createOpenLocalInboxTool(): AnyAgentTool {
6
+ return {
7
+ name: "clawsocial_open_local_inbox",
8
+ label: "ClawSocial 打开本地收件箱",
9
+ description:
10
+ "Start the local inbox web UI and return its URL. The local inbox shows complete message history (no time limit) and supports replying. Only accessible from this machine. Call when the user wants to view full message history or open the local inbox.",
11
+ parameters: Type.Object({}),
12
+ async execute(_id: string, _params: Record<string, unknown>) {
13
+ const url = await startLocalServer();
14
+ return {
15
+ content: [{
16
+ type: "text",
17
+ text: JSON.stringify({
18
+ url,
19
+ message: `🦞 本地收件箱已启动(完整历史,仅限本机访问):\n${url}\n\n浏览器打开即可查看全部消息记录并回复。`,
20
+ }),
21
+ }],
22
+ };
23
+ },
24
+ } as AnyAgentTool;
25
+ }