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.
- package/README.md +21 -3
- package/README.zh.md +21 -3
- package/index.ts +45 -33
- package/openclaw.plugin.json +13 -1
- package/package.json +1 -1
- package/src/api.ts +1 -1
- package/src/i18n.ts +142 -0
- package/src/local-server.ts +525 -0
- package/src/store.ts +8 -2
- package/src/tools/block.ts +5 -4
- package/src/tools/connect.ts +7 -7
- package/src/tools/find.ts +16 -15
- package/src/tools/inbox.ts +16 -15
- package/src/tools/match.ts +6 -5
- package/src/tools/notify_settings.ts +9 -9
- package/src/tools/open_inbox.ts +2 -1
- package/src/tools/open_local_inbox.ts +26 -0
- package/src/tools/register.ts +19 -4
- package/src/tools/session_get.ts +8 -7
- package/src/tools/session_send.ts +6 -5
- package/src/tools/sessions_list.ts +7 -6
- package/src/tools/update_profile.ts +4 -3
- package/src/ws-client.ts +18 -25
|
@@ -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, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
31
|
+
.replace(/"/g, """);
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').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: "
|
|
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
|
-
|
|
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
|
}
|
package/src/tools/block.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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
|
|
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
|
},
|
package/src/tools/connect.ts
CHANGED
|
@@ -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: "
|
|
14
|
-
target_name: Type.Optional(Type.String({ description: "
|
|
15
|
-
target_topic_tags: Type.Optional(Type.Array(Type.String(), { description: "
|
|
16
|
-
target_auto_bio: Type.Optional(Type.String({ description: "
|
|
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
|
|