@tinyclaw/plugin-channel-friends 1.1.0-staging.df9eaf8 → 2.0.0-dev.31dac1b
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/dist/index.js +29 -5
- package/dist/server.d.ts +5 -0
- package/dist/server.js +71 -4
- package/dist/store.js +9 -5
- package/dist/tools.d.ts +2 -2
- package/dist/tools.js +5 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
*
|
|
20
20
|
* userId format: "friend:<username>"
|
|
21
21
|
*/
|
|
22
|
-
import { readFileSync } from 'fs';
|
|
23
|
-
import {
|
|
24
|
-
import { fileURLToPath } from 'url';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
25
|
import { logger } from '@tinyclaw/logger';
|
|
26
|
-
import { InviteStore } from './store.js';
|
|
27
|
-
import { createFriendsTools, FRIENDS_ENABLED_CONFIG_KEY, FRIENDS_PORT_CONFIG_KEY, FRIENDS_PLUGIN_ID, } from './tools.js';
|
|
28
26
|
import { createFriendsServer } from './server.js';
|
|
27
|
+
import { InviteStore } from './store.js';
|
|
28
|
+
import { createFriendsTools, FRIENDS_ENABLED_CONFIG_KEY, FRIENDS_PLUGIN_ID, FRIENDS_PORT_CONFIG_KEY, } from './tools.js';
|
|
29
29
|
// Resolve the directory of this source file for static asset paths
|
|
30
30
|
const __filename = fileURLToPath(import.meta.url);
|
|
31
31
|
const __dirname = dirname(__filename);
|
|
@@ -42,6 +42,7 @@ const friendsPlugin = {
|
|
|
42
42
|
description: 'Invite-based web chat for friends — powered by FRIENDS.md',
|
|
43
43
|
type: 'channel',
|
|
44
44
|
version: '0.1.0',
|
|
45
|
+
channelPrefix: 'friend',
|
|
45
46
|
getPairingTools(_secrets, configManager) {
|
|
46
47
|
// Initialize the store early so pairing tools work before start()
|
|
47
48
|
if (!store) {
|
|
@@ -87,6 +88,29 @@ const friendsPlugin = {
|
|
|
87
88
|
await friendsServer.start();
|
|
88
89
|
logger.info(`Friends chat available at http://${host}:${port}/chat`);
|
|
89
90
|
},
|
|
91
|
+
async sendToUser(userId, message) {
|
|
92
|
+
if (!friendsServer) {
|
|
93
|
+
throw new Error('Friends server is not running');
|
|
94
|
+
}
|
|
95
|
+
// Parse username from the prefixed format "friend:<username>"
|
|
96
|
+
const username = userId.replace(/^friend:/, '');
|
|
97
|
+
if (!username) {
|
|
98
|
+
throw new Error(`Invalid Friends userId: ${userId}`);
|
|
99
|
+
}
|
|
100
|
+
const delivered = friendsServer.pushToUser(username, {
|
|
101
|
+
type: 'nudge',
|
|
102
|
+
content: message.content,
|
|
103
|
+
source: message.source,
|
|
104
|
+
priority: message.priority,
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
if (!delivered) {
|
|
108
|
+
logger.warn(`Friends: user "${username}" has no active push connections`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
logger.info(`Friends: sent outbound message to ${userId}`);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
90
114
|
async stop() {
|
|
91
115
|
if (friendsServer) {
|
|
92
116
|
friendsServer.stop();
|
package/dist/server.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ interface FriendsServerConfig {
|
|
|
24
24
|
export declare function createFriendsServer(config: FriendsServerConfig): {
|
|
25
25
|
start(): Promise<void>;
|
|
26
26
|
stop(): void;
|
|
27
|
+
/**
|
|
28
|
+
* Push an outbound message to all connected SSE clients for a username.
|
|
29
|
+
* Returns true if at least one client received the message.
|
|
30
|
+
*/
|
|
31
|
+
pushToUser(username: string, data: Record<string, unknown>): boolean;
|
|
27
32
|
getPort(): number;
|
|
28
33
|
};
|
|
29
34
|
export {};
|
package/dist/server.js
CHANGED
|
@@ -66,8 +66,9 @@ function authenticateFriend(request, store) {
|
|
|
66
66
|
}
|
|
67
67
|
export function createFriendsServer(config) {
|
|
68
68
|
const { port, host, store, chatHtml, onMessage, onMessageStream } = config;
|
|
69
|
-
const secure = config.secure ??
|
|
69
|
+
const secure = config.secure ?? process.env.NODE_ENV === 'production';
|
|
70
70
|
const textEncoder = new TextEncoder();
|
|
71
|
+
const pushClients = new Map();
|
|
71
72
|
let server = null;
|
|
72
73
|
return {
|
|
73
74
|
async start() {
|
|
@@ -168,6 +169,49 @@ export function createFriendsServer(config) {
|
|
|
168
169
|
store.updateNickname(friend.username, newNickname);
|
|
169
170
|
return jsonResponse({ ok: true, nickname: newNickname });
|
|
170
171
|
}
|
|
172
|
+
// ─── Push SSE stream (outbound notifications) ──────────
|
|
173
|
+
if (pathname === '/api/push' && request.method === 'GET') {
|
|
174
|
+
const friend = authenticateFriend(request, store);
|
|
175
|
+
if (!friend) {
|
|
176
|
+
return jsonResponse({ error: 'Unauthorized' }, 401);
|
|
177
|
+
}
|
|
178
|
+
const username = friend.username;
|
|
179
|
+
const stream = new ReadableStream({
|
|
180
|
+
start(controller) {
|
|
181
|
+
const pushClient = { controller, encoder: textEncoder };
|
|
182
|
+
if (!pushClients.has(username)) {
|
|
183
|
+
pushClients.set(username, new Set());
|
|
184
|
+
}
|
|
185
|
+
const clients = pushClients.get(username);
|
|
186
|
+
clients.add(pushClient);
|
|
187
|
+
// Heartbeat to keep connection alive
|
|
188
|
+
const heartbeat = setInterval(() => {
|
|
189
|
+
try {
|
|
190
|
+
controller.enqueue(textEncoder.encode(': heartbeat\n\n'));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
clearInterval(heartbeat);
|
|
194
|
+
pushClients.get(username)?.delete(pushClient);
|
|
195
|
+
}
|
|
196
|
+
}, 15_000);
|
|
197
|
+
// Clean up on abort
|
|
198
|
+
request.signal.addEventListener('abort', () => {
|
|
199
|
+
clearInterval(heartbeat);
|
|
200
|
+
pushClients.get(username)?.delete(pushClient);
|
|
201
|
+
if (pushClients.get(username)?.size === 0) {
|
|
202
|
+
pushClients.delete(username);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
return new Response(stream, {
|
|
208
|
+
headers: {
|
|
209
|
+
'Content-Type': 'text/event-stream',
|
|
210
|
+
'Cache-Control': 'no-cache',
|
|
211
|
+
Connection: 'keep-alive',
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
171
215
|
// ─── Chat API (message) ─────────────────────────────────
|
|
172
216
|
if (pathname === '/api/chat' && request.method === 'POST') {
|
|
173
217
|
const friend = authenticateFriend(request, store);
|
|
@@ -211,9 +255,7 @@ export function createFriendsServer(config) {
|
|
|
211
255
|
if (isClosed)
|
|
212
256
|
return;
|
|
213
257
|
try {
|
|
214
|
-
const data = typeof payload === 'string'
|
|
215
|
-
? payload
|
|
216
|
-
: JSON.stringify(payload);
|
|
258
|
+
const data = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
217
259
|
controller.enqueue(textEncoder.encode(`data: ${data}\n\n`));
|
|
218
260
|
if (typeof payload === 'object' &&
|
|
219
261
|
payload &&
|
|
@@ -283,9 +325,34 @@ export function createFriendsServer(config) {
|
|
|
283
325
|
if (server) {
|
|
284
326
|
server.stop();
|
|
285
327
|
server = null;
|
|
328
|
+
// Clean up all push clients
|
|
329
|
+
pushClients.clear();
|
|
286
330
|
logger.info('Friends chat server stopped');
|
|
287
331
|
}
|
|
288
332
|
},
|
|
333
|
+
/**
|
|
334
|
+
* Push an outbound message to all connected SSE clients for a username.
|
|
335
|
+
* Returns true if at least one client received the message.
|
|
336
|
+
*/
|
|
337
|
+
pushToUser(username, data) {
|
|
338
|
+
const clients = pushClients.get(username);
|
|
339
|
+
if (!clients || clients.size === 0)
|
|
340
|
+
return false;
|
|
341
|
+
const payload = `data: ${JSON.stringify(data)}\n\n`;
|
|
342
|
+
const dead = [];
|
|
343
|
+
for (const pc of clients) {
|
|
344
|
+
try {
|
|
345
|
+
pc.controller.enqueue(pc.encoder.encode(payload));
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
dead.push(pc);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const dc of dead) {
|
|
352
|
+
clients.delete(dc);
|
|
353
|
+
}
|
|
354
|
+
return clients.size > 0 || dead.length < clients.size + dead.length;
|
|
355
|
+
},
|
|
289
356
|
getPort() {
|
|
290
357
|
return server?.port || port;
|
|
291
358
|
},
|
package/dist/store.js
CHANGED
|
@@ -131,13 +131,19 @@ export class InviteStore {
|
|
|
131
131
|
return null;
|
|
132
132
|
const newCode = generateInviteCode();
|
|
133
133
|
// New code, clear session so they must re-authenticate
|
|
134
|
-
this.db.run(`UPDATE friends SET invite_code = ?, session_token = NULL WHERE username = ?`, [
|
|
134
|
+
this.db.run(`UPDATE friends SET invite_code = ?, session_token = NULL WHERE username = ?`, [
|
|
135
|
+
newCode,
|
|
136
|
+
sanitized,
|
|
137
|
+
]);
|
|
135
138
|
return newCode;
|
|
136
139
|
}
|
|
137
140
|
/** Update a friend's nickname. */
|
|
138
141
|
updateNickname(username, newNickname) {
|
|
139
142
|
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
140
|
-
const result = this.db.run(`UPDATE friends SET nickname = ? WHERE username = ?`, [
|
|
143
|
+
const result = this.db.run(`UPDATE friends SET nickname = ? WHERE username = ?`, [
|
|
144
|
+
newNickname,
|
|
145
|
+
sanitized,
|
|
146
|
+
]);
|
|
141
147
|
return result.changes > 0;
|
|
142
148
|
}
|
|
143
149
|
/** Touch last_seen timestamp. */
|
|
@@ -162,9 +168,7 @@ export class InviteStore {
|
|
|
162
168
|
/** Check if a username already exists. */
|
|
163
169
|
exists(username) {
|
|
164
170
|
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
165
|
-
const row = this.db
|
|
166
|
-
.query(`SELECT 1 FROM friends WHERE username = ?`)
|
|
167
|
-
.get(sanitized);
|
|
171
|
+
const row = this.db.query(`SELECT 1 FROM friends WHERE username = ?`).get(sanitized);
|
|
168
172
|
return row !== null;
|
|
169
173
|
}
|
|
170
174
|
rowToFriend(row) {
|
package/dist/tools.d.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* All tools are owner-only (checked by the agent loop's authority system).
|
|
9
9
|
*/
|
|
10
|
-
import type {
|
|
11
|
-
import { InviteStore } from './store.js';
|
|
10
|
+
import type { ConfigManagerInterface, Tool } from '@tinyclaw/types';
|
|
11
|
+
import type { InviteStore } from './store.js';
|
|
12
12
|
/** Config key for the enabled flag. */
|
|
13
13
|
export declare const FRIENDS_ENABLED_CONFIG_KEY = "channels.friends.enabled";
|
|
14
14
|
/** Config key for the plugin server port. */
|
package/dist/tools.js
CHANGED
|
@@ -104,7 +104,7 @@ export function createFriendsTools(store, configManager) {
|
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
name: 'friends_chat_revoke',
|
|
107
|
-
description:
|
|
107
|
+
description: "Revoke a friend's access to the Friends Web Chat. " +
|
|
108
108
|
'Their session and any pending invite code are invalidated immediately. ' +
|
|
109
109
|
'To restore access later, use friends_chat_reinvite.',
|
|
110
110
|
parameters: {
|
|
@@ -148,13 +148,11 @@ export function createFriendsTools(store, configManager) {
|
|
|
148
148
|
return 'No friends registered yet. Use friends_chat_invite to invite someone.';
|
|
149
149
|
}
|
|
150
150
|
const lines = friends.map((f) => {
|
|
151
|
-
const status = f.sessionToken
|
|
152
|
-
? 'active'
|
|
153
|
-
: f.inviteCode
|
|
154
|
-
? 'invite pending'
|
|
155
|
-
: 'revoked';
|
|
151
|
+
const status = f.sessionToken ? 'active' : f.inviteCode ? 'invite pending' : 'revoked';
|
|
156
152
|
const lastSeenDate = new Date(f.lastSeen);
|
|
157
|
-
const lastSeen = f.lastSeen && !isNaN(lastSeenDate.getTime())
|
|
153
|
+
const lastSeen = f.lastSeen && !Number.isNaN(lastSeenDate.getTime())
|
|
154
|
+
? lastSeenDate.toLocaleString()
|
|
155
|
+
: 'Unknown';
|
|
158
156
|
return `- **${f.nickname}** (@${f.username}) — ${status}, last seen: ${lastSeen}`;
|
|
159
157
|
});
|
|
160
158
|
return `**Registered Friends (${friends.length})**\n\n${lines.join('\n')}`;
|