@tinyclaw/plugin-channel-friends 1.0.1-dev.3004a38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +102 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.js +293 -0
- package/dist/store.d.ts +66 -0
- package/dist/store.js +183 -0
- package/dist/tools.d.ts +20 -0
- package/dist/tools.js +164 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @tinyclaw/plugin-channel-friends
|
|
2
|
+
|
|
3
|
+
Invite-based web chat channel plugin for Tiny Claw. Lets the owner invite friends to chat with the agent through a lightweight web interface.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
1. Enable the plugin via config or ask the agent
|
|
8
|
+
2. Ask the agent to invite a friend (e.g. "invite my friend John to friends chat")
|
|
9
|
+
3. Share the invite URL or code with your friend
|
|
10
|
+
4. Friend opens the link and starts chatting
|
|
11
|
+
|
|
12
|
+
## How It Works
|
|
13
|
+
|
|
14
|
+
- Runs an HTTP server on a configurable port (default 3001)
|
|
15
|
+
- Invite codes are single-use — redeemed into a session cookie
|
|
16
|
+
- Messages route through the agent loop as `friend:<username>`
|
|
17
|
+
|
|
18
|
+
## Management Tools
|
|
19
|
+
|
|
20
|
+
| Tool | Description |
|
|
21
|
+
|------|-------------|
|
|
22
|
+
| `friends_chat_invite` | Create a friend and generate an invite code |
|
|
23
|
+
| `friends_chat_reinvite` | Regenerate an invite for an existing friend |
|
|
24
|
+
| `friends_chat_revoke` | Revoke a friend's session and invite |
|
|
25
|
+
| `friends_chat_list` | List all friends |
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
GPLv3
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friends Chat Channel Plugin
|
|
3
|
+
*
|
|
4
|
+
* A channel plugin that provides a web-based chat interface for friends.
|
|
5
|
+
* Friends are invited by the owner via the AI agent, and each friend gets
|
|
6
|
+
* a unique invite code to authenticate.
|
|
7
|
+
*
|
|
8
|
+
* Setup:
|
|
9
|
+
* 1. Enable the plugin (add to plugins.enabled or ask the agent)
|
|
10
|
+
* 2. Ask the AI agent to invite a friend: "invite my friend John to friends chat"
|
|
11
|
+
* 3. Share the invite URL or code with your friend
|
|
12
|
+
* 4. Friend opens the link → starts chatting via FRIENDS.md
|
|
13
|
+
*
|
|
14
|
+
* Architecture:
|
|
15
|
+
* - Runs its own HTTP server on a configurable port (default 3001)
|
|
16
|
+
* - Invite codes are single-use → consumed → session cookie
|
|
17
|
+
* - All friend messages route through `context.enqueue()` as `friend:<username>`
|
|
18
|
+
* - The plugin provides tools for invite management (owner-only)
|
|
19
|
+
*
|
|
20
|
+
* userId format: "friend:<username>"
|
|
21
|
+
*/
|
|
22
|
+
import type { ChannelPlugin } from '@tinyclaw/types';
|
|
23
|
+
declare const friendsPlugin: ChannelPlugin;
|
|
24
|
+
export default friendsPlugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friends Chat Channel Plugin
|
|
3
|
+
*
|
|
4
|
+
* A channel plugin that provides a web-based chat interface for friends.
|
|
5
|
+
* Friends are invited by the owner via the AI agent, and each friend gets
|
|
6
|
+
* a unique invite code to authenticate.
|
|
7
|
+
*
|
|
8
|
+
* Setup:
|
|
9
|
+
* 1. Enable the plugin (add to plugins.enabled or ask the agent)
|
|
10
|
+
* 2. Ask the AI agent to invite a friend: "invite my friend John to friends chat"
|
|
11
|
+
* 3. Share the invite URL or code with your friend
|
|
12
|
+
* 4. Friend opens the link → starts chatting via FRIENDS.md
|
|
13
|
+
*
|
|
14
|
+
* Architecture:
|
|
15
|
+
* - Runs its own HTTP server on a configurable port (default 3001)
|
|
16
|
+
* - Invite codes are single-use → consumed → session cookie
|
|
17
|
+
* - All friend messages route through `context.enqueue()` as `friend:<username>`
|
|
18
|
+
* - The plugin provides tools for invite management (owner-only)
|
|
19
|
+
*
|
|
20
|
+
* userId format: "friend:<username>"
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync } from 'fs';
|
|
23
|
+
import { join, dirname } from 'path';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
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
|
+
import { createFriendsServer } from './server.js';
|
|
29
|
+
// Resolve the directory of this source file for static asset paths
|
|
30
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
31
|
+
const __dirname = dirname(__filename);
|
|
32
|
+
/** Load the embedded chat HTML at boot time. */
|
|
33
|
+
function loadChatHtml() {
|
|
34
|
+
const htmlPath = join(__dirname, 'chat.html');
|
|
35
|
+
return readFileSync(htmlPath, 'utf-8');
|
|
36
|
+
}
|
|
37
|
+
let store = null;
|
|
38
|
+
let friendsServer = null;
|
|
39
|
+
const friendsPlugin = {
|
|
40
|
+
id: FRIENDS_PLUGIN_ID,
|
|
41
|
+
name: 'Friends Chat',
|
|
42
|
+
description: 'Invite-based web chat for friends — powered by FRIENDS.md',
|
|
43
|
+
type: 'channel',
|
|
44
|
+
version: '0.1.0',
|
|
45
|
+
getPairingTools(_secrets, configManager) {
|
|
46
|
+
// Initialize the store early so pairing tools work before start()
|
|
47
|
+
if (!store) {
|
|
48
|
+
const dataDir = configManager.get('dataDir') || '.';
|
|
49
|
+
const dbPath = join(dataDir, 'data', 'friends.db');
|
|
50
|
+
store = new InviteStore(dbPath);
|
|
51
|
+
}
|
|
52
|
+
return createFriendsTools(store, configManager);
|
|
53
|
+
},
|
|
54
|
+
async start(context) {
|
|
55
|
+
const isEnabled = context.configManager.get(FRIENDS_ENABLED_CONFIG_KEY);
|
|
56
|
+
if (!isEnabled) {
|
|
57
|
+
logger.info('Friends chat plugin: not enabled — enable via config or ask the agent');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Initialize store if not already done by getPairingTools
|
|
61
|
+
if (!store) {
|
|
62
|
+
const dataDir = context.configManager.get('dataDir') || '.';
|
|
63
|
+
const dbPath = join(dataDir, 'data', 'friends.db');
|
|
64
|
+
store = new InviteStore(dbPath);
|
|
65
|
+
}
|
|
66
|
+
const port = context.configManager.get(FRIENDS_PORT_CONFIG_KEY) || 3001;
|
|
67
|
+
const host = process.env.HOST || context.configManager.get('friends.host') || '127.0.0.1';
|
|
68
|
+
const chatHtml = loadChatHtml();
|
|
69
|
+
friendsServer = createFriendsServer({
|
|
70
|
+
port,
|
|
71
|
+
host,
|
|
72
|
+
store,
|
|
73
|
+
chatHtml,
|
|
74
|
+
async onMessage(message, userId) {
|
|
75
|
+
return context.enqueue(userId, message);
|
|
76
|
+
},
|
|
77
|
+
async onMessageStream(message, userId, send) {
|
|
78
|
+
// Use the streaming enqueue if available, otherwise fallback
|
|
79
|
+
const response = await context.enqueue(userId, message);
|
|
80
|
+
// If enqueue returned a string directly (non-streaming), send it as done
|
|
81
|
+
if (response !== undefined && response !== null) {
|
|
82
|
+
send({ type: 'text', content: response });
|
|
83
|
+
send({ type: 'done' });
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
await friendsServer.start();
|
|
88
|
+
logger.info(`Friends chat available at http://${host}:${port}/chat`);
|
|
89
|
+
},
|
|
90
|
+
async stop() {
|
|
91
|
+
if (friendsServer) {
|
|
92
|
+
friendsServer.stop();
|
|
93
|
+
friendsServer = null;
|
|
94
|
+
}
|
|
95
|
+
if (store) {
|
|
96
|
+
store.close();
|
|
97
|
+
store = null;
|
|
98
|
+
}
|
|
99
|
+
logger.info('Friends chat plugin stopped');
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
export default friendsPlugin;
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friends Chat HTTP Server
|
|
3
|
+
*
|
|
4
|
+
* Lightweight Bun HTTP server that exposes:
|
|
5
|
+
* - GET /chat → Friends chat UI (static HTML)
|
|
6
|
+
* - GET /chat?invite=X → Auto-redeem invite, set cookie, show chat
|
|
7
|
+
* - POST /api/chat → Chat API (SSE stream or JSON)
|
|
8
|
+
* - POST /api/nickname → Update friend's nickname
|
|
9
|
+
* - GET /api/health → Health check
|
|
10
|
+
*
|
|
11
|
+
* Authentication is cookie-based. The invite code is consumed on first use
|
|
12
|
+
* and replaced with a long-lived session cookie (`tc_friend_session`).
|
|
13
|
+
*/
|
|
14
|
+
import type { InviteStore } from './store.js';
|
|
15
|
+
interface FriendsServerConfig {
|
|
16
|
+
port: number;
|
|
17
|
+
host: string;
|
|
18
|
+
store: InviteStore;
|
|
19
|
+
chatHtml: string;
|
|
20
|
+
secure?: boolean;
|
|
21
|
+
onMessage: (message: string, userId: string) => Promise<string>;
|
|
22
|
+
onMessageStream?: (message: string, userId: string, send: (payload: unknown) => void) => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export declare function createFriendsServer(config: FriendsServerConfig): {
|
|
25
|
+
start(): Promise<void>;
|
|
26
|
+
stop(): void;
|
|
27
|
+
getPort(): number;
|
|
28
|
+
};
|
|
29
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friends Chat HTTP Server
|
|
3
|
+
*
|
|
4
|
+
* Lightweight Bun HTTP server that exposes:
|
|
5
|
+
* - GET /chat → Friends chat UI (static HTML)
|
|
6
|
+
* - GET /chat?invite=X → Auto-redeem invite, set cookie, show chat
|
|
7
|
+
* - POST /api/chat → Chat API (SSE stream or JSON)
|
|
8
|
+
* - POST /api/nickname → Update friend's nickname
|
|
9
|
+
* - GET /api/health → Health check
|
|
10
|
+
*
|
|
11
|
+
* Authentication is cookie-based. The invite code is consumed on first use
|
|
12
|
+
* and replaced with a long-lived session cookie (`tc_friend_session`).
|
|
13
|
+
*/
|
|
14
|
+
import { logger } from '@tinyclaw/logger';
|
|
15
|
+
const COOKIE_NAME = 'tc_friend_session';
|
|
16
|
+
const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds
|
|
17
|
+
/**
|
|
18
|
+
* Parse the session token from the cookie header.
|
|
19
|
+
*/
|
|
20
|
+
function getSessionToken(request) {
|
|
21
|
+
const cookieHeader = request.headers.get('cookie') || '';
|
|
22
|
+
const match = cookieHeader.match(new RegExp(`${COOKIE_NAME}=([^;]+)`));
|
|
23
|
+
return match ? match[1] : null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Build a Set-Cookie header for the friend session.
|
|
27
|
+
*/
|
|
28
|
+
function buildSessionCookie(token, secure = false) {
|
|
29
|
+
let cookie = `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_MAX_AGE}`;
|
|
30
|
+
if (secure)
|
|
31
|
+
cookie += '; Secure';
|
|
32
|
+
return cookie;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* JSON response helper.
|
|
36
|
+
*/
|
|
37
|
+
function jsonResponse(data, status = 200, headers) {
|
|
38
|
+
return new Response(JSON.stringify(data), {
|
|
39
|
+
status,
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
...headers,
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* HTML response helper.
|
|
48
|
+
*/
|
|
49
|
+
function htmlResponse(html, status = 200, headers) {
|
|
50
|
+
return new Response(html, {
|
|
51
|
+
status,
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
54
|
+
...headers,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Authenticate a request — returns the friend user or null.
|
|
60
|
+
*/
|
|
61
|
+
function authenticateFriend(request, store) {
|
|
62
|
+
const token = getSessionToken(request);
|
|
63
|
+
if (!token)
|
|
64
|
+
return null;
|
|
65
|
+
return store.getBySessionToken(token);
|
|
66
|
+
}
|
|
67
|
+
export function createFriendsServer(config) {
|
|
68
|
+
const { port, host, store, chatHtml, onMessage, onMessageStream } = config;
|
|
69
|
+
const secure = config.secure ?? (process.env.NODE_ENV === 'production');
|
|
70
|
+
const textEncoder = new TextEncoder();
|
|
71
|
+
let server = null;
|
|
72
|
+
return {
|
|
73
|
+
async start() {
|
|
74
|
+
server = Bun.serve({
|
|
75
|
+
port,
|
|
76
|
+
hostname: host,
|
|
77
|
+
async fetch(request) {
|
|
78
|
+
const url = new URL(request.url);
|
|
79
|
+
const pathname = url.pathname;
|
|
80
|
+
// ─── Health check ───────────────────────────────────────
|
|
81
|
+
if (pathname === '/api/health' && request.method === 'GET') {
|
|
82
|
+
return jsonResponse({ ok: true, service: 'friends-chat' });
|
|
83
|
+
}
|
|
84
|
+
// ─── Chat page ──────────────────────────────────────────
|
|
85
|
+
if ((pathname === '/chat' || pathname === '/') && request.method === 'GET') {
|
|
86
|
+
const inviteCode = url.searchParams.get('invite');
|
|
87
|
+
// If invite code provided, try to redeem
|
|
88
|
+
if (inviteCode) {
|
|
89
|
+
const result = store.redeemInvite(inviteCode);
|
|
90
|
+
if (result) {
|
|
91
|
+
logger.info(`Friend "${result.friend.username}" redeemed invite`);
|
|
92
|
+
// Redirect to /chat (without invite param) with session cookie
|
|
93
|
+
return new Response(null, {
|
|
94
|
+
status: 302,
|
|
95
|
+
headers: {
|
|
96
|
+
Location: '/chat',
|
|
97
|
+
'Set-Cookie': buildSessionCookie(result.sessionToken, secure),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
// Invalid code — show chat page (will show invite code input)
|
|
102
|
+
return htmlResponse(chatHtml);
|
|
103
|
+
}
|
|
104
|
+
// No invite code — serve the chat page
|
|
105
|
+
// Auth check happens client-side via /api/auth/status
|
|
106
|
+
return htmlResponse(chatHtml);
|
|
107
|
+
}
|
|
108
|
+
// ─── Auth status ────────────────────────────────────────
|
|
109
|
+
if (pathname === '/api/auth/status' && request.method === 'GET') {
|
|
110
|
+
const friend = authenticateFriend(request, store);
|
|
111
|
+
if (friend) {
|
|
112
|
+
store.touchLastSeen(friend.username);
|
|
113
|
+
return jsonResponse({
|
|
114
|
+
authenticated: true,
|
|
115
|
+
username: friend.username,
|
|
116
|
+
nickname: friend.nickname,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return jsonResponse({ authenticated: false });
|
|
120
|
+
}
|
|
121
|
+
// ─── Redeem invite via API (for manual code entry) ──────
|
|
122
|
+
if (pathname === '/api/auth/redeem' && request.method === 'POST') {
|
|
123
|
+
let body = null;
|
|
124
|
+
try {
|
|
125
|
+
body = await request.json();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return jsonResponse({ error: 'Invalid JSON' }, 400);
|
|
129
|
+
}
|
|
130
|
+
if (typeof body?.code !== 'string') {
|
|
131
|
+
return jsonResponse({ error: 'Invite code must be a string' }, 400);
|
|
132
|
+
}
|
|
133
|
+
const code = body.code.trim();
|
|
134
|
+
if (!code) {
|
|
135
|
+
return jsonResponse({ error: 'Invite code is required' }, 400);
|
|
136
|
+
}
|
|
137
|
+
const result = store.redeemInvite(code);
|
|
138
|
+
if (!result) {
|
|
139
|
+
return jsonResponse({ error: 'Invalid or expired invite code' }, 401);
|
|
140
|
+
}
|
|
141
|
+
logger.info(`Friend "${result.friend.username}" redeemed invite via code entry`);
|
|
142
|
+
return jsonResponse({
|
|
143
|
+
authenticated: true,
|
|
144
|
+
username: result.friend.username,
|
|
145
|
+
nickname: result.friend.nickname,
|
|
146
|
+
}, 200, { 'Set-Cookie': buildSessionCookie(result.sessionToken, secure) });
|
|
147
|
+
}
|
|
148
|
+
// ─── Update nickname ────────────────────────────────────
|
|
149
|
+
if (pathname === '/api/nickname' && request.method === 'POST') {
|
|
150
|
+
const friend = authenticateFriend(request, store);
|
|
151
|
+
if (!friend) {
|
|
152
|
+
return jsonResponse({ error: 'Unauthorized' }, 401);
|
|
153
|
+
}
|
|
154
|
+
let body = null;
|
|
155
|
+
try {
|
|
156
|
+
body = await request.json();
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return jsonResponse({ error: 'Invalid JSON' }, 400);
|
|
160
|
+
}
|
|
161
|
+
if (typeof body?.nickname !== 'string') {
|
|
162
|
+
return jsonResponse({ error: 'Nickname must be a string' }, 400);
|
|
163
|
+
}
|
|
164
|
+
const newNickname = body.nickname.trim();
|
|
165
|
+
if (!newNickname || newNickname.length > 64) {
|
|
166
|
+
return jsonResponse({ error: 'Nickname must be 1–64 characters' }, 400);
|
|
167
|
+
}
|
|
168
|
+
store.updateNickname(friend.username, newNickname);
|
|
169
|
+
return jsonResponse({ ok: true, nickname: newNickname });
|
|
170
|
+
}
|
|
171
|
+
// ─── Chat API (message) ─────────────────────────────────
|
|
172
|
+
if (pathname === '/api/chat' && request.method === 'POST') {
|
|
173
|
+
const friend = authenticateFriend(request, store);
|
|
174
|
+
if (!friend) {
|
|
175
|
+
return jsonResponse({ error: 'Unauthorized' }, 401);
|
|
176
|
+
}
|
|
177
|
+
let body = null;
|
|
178
|
+
try {
|
|
179
|
+
body = await request.json();
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return jsonResponse({ error: 'Invalid JSON' }, 400);
|
|
183
|
+
}
|
|
184
|
+
if (typeof body?.message !== 'string') {
|
|
185
|
+
return jsonResponse({ error: 'Message is required' }, 400);
|
|
186
|
+
}
|
|
187
|
+
const message = body.message.trim();
|
|
188
|
+
if (!message) {
|
|
189
|
+
return jsonResponse({ error: 'Message is required' }, 400);
|
|
190
|
+
}
|
|
191
|
+
store.touchLastSeen(friend.username);
|
|
192
|
+
const userId = `friend:${friend.username}`;
|
|
193
|
+
// SSE streaming response
|
|
194
|
+
if (onMessageStream) {
|
|
195
|
+
const stream = new ReadableStream({
|
|
196
|
+
start(controller) {
|
|
197
|
+
let isClosed = false;
|
|
198
|
+
const heartbeat = setInterval(() => {
|
|
199
|
+
if (isClosed) {
|
|
200
|
+
clearInterval(heartbeat);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
controller.enqueue(textEncoder.encode(': heartbeat\n\n'));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
clearInterval(heartbeat);
|
|
208
|
+
}
|
|
209
|
+
}, 8_000);
|
|
210
|
+
const send = (payload) => {
|
|
211
|
+
if (isClosed)
|
|
212
|
+
return;
|
|
213
|
+
try {
|
|
214
|
+
const data = typeof payload === 'string'
|
|
215
|
+
? payload
|
|
216
|
+
: JSON.stringify(payload);
|
|
217
|
+
controller.enqueue(textEncoder.encode(`data: ${data}\n\n`));
|
|
218
|
+
if (typeof payload === 'object' &&
|
|
219
|
+
payload &&
|
|
220
|
+
payload.type === 'done') {
|
|
221
|
+
isClosed = true;
|
|
222
|
+
clearInterval(heartbeat);
|
|
223
|
+
controller.close();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
isClosed = true;
|
|
228
|
+
clearInterval(heartbeat);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
onMessageStream(message, userId, send)
|
|
232
|
+
.then(() => {
|
|
233
|
+
if (!isClosed) {
|
|
234
|
+
isClosed = true;
|
|
235
|
+
clearInterval(heartbeat);
|
|
236
|
+
try {
|
|
237
|
+
controller.close();
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
.catch((error) => {
|
|
243
|
+
if (!isClosed) {
|
|
244
|
+
send({
|
|
245
|
+
type: 'error',
|
|
246
|
+
error: error?.message || 'Streaming error.',
|
|
247
|
+
});
|
|
248
|
+
isClosed = true;
|
|
249
|
+
clearInterval(heartbeat);
|
|
250
|
+
try {
|
|
251
|
+
controller.close();
|
|
252
|
+
}
|
|
253
|
+
catch { }
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
return new Response(stream, {
|
|
259
|
+
headers: {
|
|
260
|
+
'Content-Type': 'text/event-stream',
|
|
261
|
+
'Cache-Control': 'no-cache',
|
|
262
|
+
Connection: 'keep-alive',
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// Fallback: non-streaming
|
|
267
|
+
try {
|
|
268
|
+
const responseText = await onMessage(message, userId);
|
|
269
|
+
return jsonResponse({ content: responseText });
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
logger.error(`Friends chat onMessage error for userId=${userId}: ${error?.message}`);
|
|
273
|
+
return jsonResponse({ error: 'Internal server error' }, 500);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// ─── 404 fallback ───────────────────────────────────────
|
|
277
|
+
return jsonResponse({ error: 'Not found' }, 404);
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
logger.info(`Friends chat server listening on ${host}:${port}`);
|
|
281
|
+
},
|
|
282
|
+
stop() {
|
|
283
|
+
if (server) {
|
|
284
|
+
server.stop();
|
|
285
|
+
server = null;
|
|
286
|
+
logger.info('Friends chat server stopped');
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
getPort() {
|
|
290
|
+
return server?.port || port;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friends Invite Store
|
|
3
|
+
*
|
|
4
|
+
* Manages invite codes, friend sessions, and friend user records.
|
|
5
|
+
* Uses a SQLite database (via Bun's built-in `bun:sqlite`) stored
|
|
6
|
+
* in the Tiny Claw data directory.
|
|
7
|
+
*
|
|
8
|
+
* Each friend has:
|
|
9
|
+
* - username: permanent identifier, set by owner
|
|
10
|
+
* - nickname: display name, changeable by the friend
|
|
11
|
+
* - inviteCode: single-use, consumed on first use → sets session cookie
|
|
12
|
+
* - sessionToken: long-lived browser auth after invite is redeemed
|
|
13
|
+
*/
|
|
14
|
+
export interface FriendUser {
|
|
15
|
+
username: string;
|
|
16
|
+
nickname: string;
|
|
17
|
+
inviteCode: string | null;
|
|
18
|
+
sessionToken: string | null;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
lastSeen: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Generate a short, URL-safe invite code.
|
|
24
|
+
* 8 chars from a base-62 alphabet — ~47 bits of entropy.
|
|
25
|
+
*/
|
|
26
|
+
export declare function generateInviteCode(): string;
|
|
27
|
+
/**
|
|
28
|
+
* Generate a cryptographic session token.
|
|
29
|
+
* 32 hex chars — 128 bits of entropy.
|
|
30
|
+
*/
|
|
31
|
+
export declare function generateSessionToken(): string;
|
|
32
|
+
export declare class InviteStore {
|
|
33
|
+
private db;
|
|
34
|
+
constructor(dbPath: string);
|
|
35
|
+
private migrate;
|
|
36
|
+
/** Create a new friend with a fresh invite code. */
|
|
37
|
+
createFriend(username: string, nickname?: string): FriendUser;
|
|
38
|
+
/** Look up a friend by username. */
|
|
39
|
+
getFriend(username: string): FriendUser | null;
|
|
40
|
+
/** Look up a friend by their invite code. */
|
|
41
|
+
getByInviteCode(code: string): FriendUser | null;
|
|
42
|
+
/** Look up a friend by their session token. */
|
|
43
|
+
getBySessionToken(token: string): FriendUser | null;
|
|
44
|
+
/**
|
|
45
|
+
* Redeem an invite code — consumes the code and sets a session token.
|
|
46
|
+
* Returns the session token on success, null if code is invalid.
|
|
47
|
+
*/
|
|
48
|
+
redeemInvite(code: string): {
|
|
49
|
+
sessionToken: string;
|
|
50
|
+
friend: FriendUser;
|
|
51
|
+
} | null;
|
|
52
|
+
/** Regenerate an invite code for an existing friend (invalidates old session). */
|
|
53
|
+
regenerateInvite(username: string): string | null;
|
|
54
|
+
/** Update a friend's nickname. */
|
|
55
|
+
updateNickname(username: string, newNickname: string): boolean;
|
|
56
|
+
/** Touch last_seen timestamp. */
|
|
57
|
+
touchLastSeen(username: string): void;
|
|
58
|
+
/** Revoke a friend's access — clears session and invite code. */
|
|
59
|
+
revokeFriend(username: string): boolean;
|
|
60
|
+
/** List all friends. */
|
|
61
|
+
listFriends(): FriendUser[];
|
|
62
|
+
/** Check if a username already exists. */
|
|
63
|
+
exists(username: string): boolean;
|
|
64
|
+
private rowToFriend;
|
|
65
|
+
close(): void;
|
|
66
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friends Invite Store
|
|
3
|
+
*
|
|
4
|
+
* Manages invite codes, friend sessions, and friend user records.
|
|
5
|
+
* Uses a SQLite database (via Bun's built-in `bun:sqlite`) stored
|
|
6
|
+
* in the Tiny Claw data directory.
|
|
7
|
+
*
|
|
8
|
+
* Each friend has:
|
|
9
|
+
* - username: permanent identifier, set by owner
|
|
10
|
+
* - nickname: display name, changeable by the friend
|
|
11
|
+
* - inviteCode: single-use, consumed on first use → sets session cookie
|
|
12
|
+
* - sessionToken: long-lived browser auth after invite is redeemed
|
|
13
|
+
*/
|
|
14
|
+
import { Database } from 'bun:sqlite';
|
|
15
|
+
import { logger } from '@tinyclaw/logger';
|
|
16
|
+
/**
|
|
17
|
+
* Generate a short, URL-safe invite code.
|
|
18
|
+
* 8 chars from a base-62 alphabet — ~47 bits of entropy.
|
|
19
|
+
*/
|
|
20
|
+
export function generateInviteCode() {
|
|
21
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
22
|
+
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
23
|
+
return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join('');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Generate a cryptographic session token.
|
|
27
|
+
* 32 hex chars — 128 bits of entropy.
|
|
28
|
+
*/
|
|
29
|
+
export function generateSessionToken() {
|
|
30
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
31
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
32
|
+
}
|
|
33
|
+
export class InviteStore {
|
|
34
|
+
db;
|
|
35
|
+
constructor(dbPath) {
|
|
36
|
+
this.db = new Database(dbPath);
|
|
37
|
+
this.db.exec('PRAGMA journal_mode = WAL;');
|
|
38
|
+
this.db.exec('PRAGMA foreign_keys = ON;');
|
|
39
|
+
this.migrate();
|
|
40
|
+
}
|
|
41
|
+
migrate() {
|
|
42
|
+
this.db.exec(`
|
|
43
|
+
CREATE TABLE IF NOT EXISTS friends (
|
|
44
|
+
username TEXT PRIMARY KEY,
|
|
45
|
+
nickname TEXT NOT NULL,
|
|
46
|
+
invite_code TEXT UNIQUE,
|
|
47
|
+
session_token TEXT UNIQUE,
|
|
48
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
49
|
+
last_seen INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
50
|
+
);
|
|
51
|
+
`);
|
|
52
|
+
logger.info('Friends invite store ready');
|
|
53
|
+
}
|
|
54
|
+
/** Create a new friend with a fresh invite code. */
|
|
55
|
+
createFriend(username, nickname) {
|
|
56
|
+
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
57
|
+
if (this.exists(sanitized)) {
|
|
58
|
+
throw new Error(`A friend with username "${sanitized}" already exists`);
|
|
59
|
+
}
|
|
60
|
+
const displayName = nickname || username;
|
|
61
|
+
const inviteCode = generateInviteCode();
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
this.db.run(`INSERT INTO friends (username, nickname, invite_code, created_at, last_seen)
|
|
64
|
+
VALUES (?, ?, ?, ?, ?)`, [sanitized, displayName, inviteCode, now, now]);
|
|
65
|
+
return {
|
|
66
|
+
username: sanitized,
|
|
67
|
+
nickname: displayName,
|
|
68
|
+
inviteCode,
|
|
69
|
+
sessionToken: null,
|
|
70
|
+
createdAt: now,
|
|
71
|
+
lastSeen: now,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Look up a friend by username. */
|
|
75
|
+
getFriend(username) {
|
|
76
|
+
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
77
|
+
const row = this.db
|
|
78
|
+
.query(`SELECT username, nickname, invite_code, session_token, created_at, last_seen
|
|
79
|
+
FROM friends WHERE username = ?`)
|
|
80
|
+
.get(sanitized);
|
|
81
|
+
if (!row)
|
|
82
|
+
return null;
|
|
83
|
+
return this.rowToFriend(row);
|
|
84
|
+
}
|
|
85
|
+
/** Look up a friend by their invite code. */
|
|
86
|
+
getByInviteCode(code) {
|
|
87
|
+
const row = this.db
|
|
88
|
+
.query(`SELECT username, nickname, invite_code, session_token, created_at, last_seen
|
|
89
|
+
FROM friends WHERE invite_code = ?`)
|
|
90
|
+
.get(code);
|
|
91
|
+
if (!row)
|
|
92
|
+
return null;
|
|
93
|
+
return this.rowToFriend(row);
|
|
94
|
+
}
|
|
95
|
+
/** Look up a friend by their session token. */
|
|
96
|
+
getBySessionToken(token) {
|
|
97
|
+
if (!token)
|
|
98
|
+
return null;
|
|
99
|
+
const row = this.db
|
|
100
|
+
.query(`SELECT username, nickname, invite_code, session_token, created_at, last_seen
|
|
101
|
+
FROM friends WHERE session_token = ?`)
|
|
102
|
+
.get(token);
|
|
103
|
+
if (!row)
|
|
104
|
+
return null;
|
|
105
|
+
return this.rowToFriend(row);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Redeem an invite code — consumes the code and sets a session token.
|
|
109
|
+
* Returns the session token on success, null if code is invalid.
|
|
110
|
+
*/
|
|
111
|
+
redeemInvite(code) {
|
|
112
|
+
const sessionToken = generateSessionToken();
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
// Atomic: consume the invite code and set the session token in one statement
|
|
115
|
+
const row = this.db
|
|
116
|
+
.query(`UPDATE friends SET invite_code = NULL, session_token = ?, last_seen = ?
|
|
117
|
+
WHERE invite_code = ? RETURNING username, nickname, invite_code, session_token, created_at, last_seen`)
|
|
118
|
+
.get(sessionToken, now, code);
|
|
119
|
+
if (!row)
|
|
120
|
+
return null;
|
|
121
|
+
return {
|
|
122
|
+
sessionToken,
|
|
123
|
+
friend: this.rowToFriend(row),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Regenerate an invite code for an existing friend (invalidates old session). */
|
|
127
|
+
regenerateInvite(username) {
|
|
128
|
+
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
129
|
+
const friend = this.getFriend(sanitized);
|
|
130
|
+
if (!friend)
|
|
131
|
+
return null;
|
|
132
|
+
const newCode = generateInviteCode();
|
|
133
|
+
// New code, clear session so they must re-authenticate
|
|
134
|
+
this.db.run(`UPDATE friends SET invite_code = ?, session_token = NULL WHERE username = ?`, [newCode, sanitized]);
|
|
135
|
+
return newCode;
|
|
136
|
+
}
|
|
137
|
+
/** Update a friend's nickname. */
|
|
138
|
+
updateNickname(username, newNickname) {
|
|
139
|
+
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
140
|
+
const result = this.db.run(`UPDATE friends SET nickname = ? WHERE username = ?`, [newNickname, sanitized]);
|
|
141
|
+
return result.changes > 0;
|
|
142
|
+
}
|
|
143
|
+
/** Touch last_seen timestamp. */
|
|
144
|
+
touchLastSeen(username) {
|
|
145
|
+
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
146
|
+
this.db.run(`UPDATE friends SET last_seen = ? WHERE username = ?`, [Date.now(), sanitized]);
|
|
147
|
+
}
|
|
148
|
+
/** Revoke a friend's access — clears session and invite code. */
|
|
149
|
+
revokeFriend(username) {
|
|
150
|
+
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
151
|
+
const result = this.db.run(`UPDATE friends SET invite_code = NULL, session_token = NULL WHERE username = ?`, [sanitized]);
|
|
152
|
+
return result.changes > 0;
|
|
153
|
+
}
|
|
154
|
+
/** List all friends. */
|
|
155
|
+
listFriends() {
|
|
156
|
+
const rows = this.db
|
|
157
|
+
.query(`SELECT username, nickname, invite_code, session_token, created_at, last_seen
|
|
158
|
+
FROM friends ORDER BY created_at ASC`)
|
|
159
|
+
.all();
|
|
160
|
+
return rows.map((row) => this.rowToFriend(row));
|
|
161
|
+
}
|
|
162
|
+
/** Check if a username already exists. */
|
|
163
|
+
exists(username) {
|
|
164
|
+
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);
|
|
168
|
+
return row !== null;
|
|
169
|
+
}
|
|
170
|
+
rowToFriend(row) {
|
|
171
|
+
return {
|
|
172
|
+
username: row.username,
|
|
173
|
+
nickname: row.nickname,
|
|
174
|
+
inviteCode: row.invite_code || null,
|
|
175
|
+
sessionToken: row.session_token || null,
|
|
176
|
+
createdAt: row.created_at,
|
|
177
|
+
lastSeen: row.last_seen,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
close() {
|
|
181
|
+
this.db.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friends Chat Tools
|
|
3
|
+
*
|
|
4
|
+
* Agent tools for managing friend invites. These are registered as pairing
|
|
5
|
+
* tools so the AI agent can create invite links, regenerate codes, revoke
|
|
6
|
+
* access, and list friends conversationally.
|
|
7
|
+
*
|
|
8
|
+
* All tools are owner-only (checked by the agent loop's authority system).
|
|
9
|
+
*/
|
|
10
|
+
import type { Tool, ConfigManagerInterface } from '@tinyclaw/types';
|
|
11
|
+
import { InviteStore } from './store.js';
|
|
12
|
+
/** Config key for the enabled flag. */
|
|
13
|
+
export declare const FRIENDS_ENABLED_CONFIG_KEY = "channels.friends.enabled";
|
|
14
|
+
/** Config key for the plugin server port. */
|
|
15
|
+
export declare const FRIENDS_PORT_CONFIG_KEY = "channels.friends.port";
|
|
16
|
+
/** Config key for the base URL (for generating invite links). */
|
|
17
|
+
export declare const FRIENDS_BASE_URL_CONFIG_KEY = "channels.friends.baseUrl";
|
|
18
|
+
/** The plugin's package ID. */
|
|
19
|
+
export declare const FRIENDS_PLUGIN_ID = "@tinyclaw/plugin-channel-friends";
|
|
20
|
+
export declare function createFriendsTools(store: InviteStore, configManager: ConfigManagerInterface): Tool[];
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Friends Chat Tools
|
|
3
|
+
*
|
|
4
|
+
* Agent tools for managing friend invites. These are registered as pairing
|
|
5
|
+
* tools so the AI agent can create invite links, regenerate codes, revoke
|
|
6
|
+
* access, and list friends conversationally.
|
|
7
|
+
*
|
|
8
|
+
* All tools are owner-only (checked by the agent loop's authority system).
|
|
9
|
+
*/
|
|
10
|
+
/** Config key for the enabled flag. */
|
|
11
|
+
export const FRIENDS_ENABLED_CONFIG_KEY = 'channels.friends.enabled';
|
|
12
|
+
/** Config key for the plugin server port. */
|
|
13
|
+
export const FRIENDS_PORT_CONFIG_KEY = 'channels.friends.port';
|
|
14
|
+
/** Config key for the base URL (for generating invite links). */
|
|
15
|
+
export const FRIENDS_BASE_URL_CONFIG_KEY = 'channels.friends.baseUrl';
|
|
16
|
+
/** The plugin's package ID. */
|
|
17
|
+
export const FRIENDS_PLUGIN_ID = '@tinyclaw/plugin-channel-friends';
|
|
18
|
+
export function createFriendsTools(store, configManager) {
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
name: 'friends_chat_invite',
|
|
22
|
+
description: 'Create a new friend and generate an invite link for the Friends Web Chat. ' +
|
|
23
|
+
'The owner must provide a unique username. An invite code and URL will be generated. ' +
|
|
24
|
+
'The friend uses the link or code to start chatting. ' +
|
|
25
|
+
'The invite code is single-use — once redeemed, the friend gets a session cookie.',
|
|
26
|
+
parameters: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
username: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Unique username for the friend (lowercase alphanumeric + underscores). ' +
|
|
32
|
+
'This is permanent and identifies the friend in FRIENDS.md.',
|
|
33
|
+
},
|
|
34
|
+
nickname: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Optional display name for the friend. Defaults to the username. ' +
|
|
37
|
+
'The friend can change this later.',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ['username'],
|
|
41
|
+
},
|
|
42
|
+
async execute(args) {
|
|
43
|
+
const username = (args.username || '').trim();
|
|
44
|
+
if (!username) {
|
|
45
|
+
return 'Error: username is required.';
|
|
46
|
+
}
|
|
47
|
+
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
48
|
+
if (sanitized.length < 2 || sanitized.length > 32) {
|
|
49
|
+
return 'Error: username must be 2–32 characters (letters, numbers, underscores).';
|
|
50
|
+
}
|
|
51
|
+
if (store.exists(sanitized)) {
|
|
52
|
+
return `Error: a friend with username "${sanitized}" already exists. Use friends_chat_reinvite to generate a new invite code for them.`;
|
|
53
|
+
}
|
|
54
|
+
const nickname = (args.nickname || '').trim() || undefined;
|
|
55
|
+
const friend = store.createFriend(sanitized, nickname);
|
|
56
|
+
const baseUrl = configManager.get(FRIENDS_BASE_URL_CONFIG_KEY) || '';
|
|
57
|
+
const port = configManager.get(FRIENDS_PORT_CONFIG_KEY) || 3001;
|
|
58
|
+
const base = baseUrl || `http://localhost:${port}`;
|
|
59
|
+
const inviteUrl = `${base}/chat?invite=${friend.inviteCode}`;
|
|
60
|
+
return (`Friend "${friend.nickname}" (username: ${friend.username}) created!\n\n` +
|
|
61
|
+
`Invite URL: ${inviteUrl}\n` +
|
|
62
|
+
`Invite code: ${friend.inviteCode}\n\n` +
|
|
63
|
+
`Share the URL or code with your friend. ` +
|
|
64
|
+
`The code is single-use — once they open the link and start chatting, ` +
|
|
65
|
+
`their browser is authenticated. If they switch browsers or clear cookies, ` +
|
|
66
|
+
`use friends_chat_reinvite to generate a new code for them.`);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'friends_chat_reinvite',
|
|
71
|
+
description: 'Generate a new invite code for an existing friend. ' +
|
|
72
|
+
'Use this when a friend switches browsers, clears cookies, or loses access. ' +
|
|
73
|
+
'The old session is invalidated — the friend must use the new code to re-authenticate.',
|
|
74
|
+
parameters: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
username: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
description: 'The username of the existing friend to re-invite.',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ['username'],
|
|
83
|
+
},
|
|
84
|
+
async execute(args) {
|
|
85
|
+
const username = (args.username || '').trim();
|
|
86
|
+
if (!username) {
|
|
87
|
+
return 'Error: username is required.';
|
|
88
|
+
}
|
|
89
|
+
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
90
|
+
const newCode = store.regenerateInvite(sanitized);
|
|
91
|
+
if (!newCode) {
|
|
92
|
+
return `Error: no friend found with username "${sanitized}". Use friends_chat_invite to create a new friend.`;
|
|
93
|
+
}
|
|
94
|
+
const baseUrl = configManager.get(FRIENDS_BASE_URL_CONFIG_KEY) || '';
|
|
95
|
+
const port = configManager.get(FRIENDS_PORT_CONFIG_KEY) || 3001;
|
|
96
|
+
const base = baseUrl || `http://localhost:${port}`;
|
|
97
|
+
const inviteUrl = `${base}/chat?invite=${newCode}`;
|
|
98
|
+
return (`New invite generated for "${sanitized}"!\n\n` +
|
|
99
|
+
`Invite URL: ${inviteUrl}\n` +
|
|
100
|
+
`Invite code: ${newCode}\n\n` +
|
|
101
|
+
`Their previous session has been invalidated. ` +
|
|
102
|
+
`Share the new link or code with them.`);
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'friends_chat_revoke',
|
|
107
|
+
description: 'Revoke a friend\'s access to the Friends Web Chat. ' +
|
|
108
|
+
'Their session and any pending invite code are invalidated immediately. ' +
|
|
109
|
+
'To restore access later, use friends_chat_reinvite.',
|
|
110
|
+
parameters: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
username: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
description: 'The username of the friend to revoke.',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
required: ['username'],
|
|
119
|
+
},
|
|
120
|
+
async execute(args) {
|
|
121
|
+
const username = (args.username || '').trim();
|
|
122
|
+
if (!username) {
|
|
123
|
+
return 'Error: username is required.';
|
|
124
|
+
}
|
|
125
|
+
const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
126
|
+
const revoked = store.revokeFriend(sanitized);
|
|
127
|
+
if (!revoked) {
|
|
128
|
+
return `Error: no friend found with username "${sanitized}".`;
|
|
129
|
+
}
|
|
130
|
+
return (`Access revoked for "${sanitized}". ` +
|
|
131
|
+
`Their session cookie and invite code have been invalidated. ` +
|
|
132
|
+
`Use friends_chat_reinvite to restore access.`);
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'friends_chat_list',
|
|
137
|
+
description: 'List all registered friends and their status. ' +
|
|
138
|
+
'Shows username, nickname, whether they have an active session or pending invite, ' +
|
|
139
|
+
'and when they were last seen.',
|
|
140
|
+
parameters: {
|
|
141
|
+
type: 'object',
|
|
142
|
+
properties: {},
|
|
143
|
+
required: [],
|
|
144
|
+
},
|
|
145
|
+
async execute() {
|
|
146
|
+
const friends = store.listFriends();
|
|
147
|
+
if (friends.length === 0) {
|
|
148
|
+
return 'No friends registered yet. Use friends_chat_invite to invite someone.';
|
|
149
|
+
}
|
|
150
|
+
const lines = friends.map((f) => {
|
|
151
|
+
const status = f.sessionToken
|
|
152
|
+
? 'active'
|
|
153
|
+
: f.inviteCode
|
|
154
|
+
? 'invite pending'
|
|
155
|
+
: 'revoked';
|
|
156
|
+
const lastSeenDate = new Date(f.lastSeen);
|
|
157
|
+
const lastSeen = f.lastSeen && !isNaN(lastSeenDate.getTime()) ? lastSeenDate.toLocaleString() : 'Unknown';
|
|
158
|
+
return `- **${f.nickname}** (@${f.username}) — ${status}, last seen: ${lastSeen}`;
|
|
159
|
+
});
|
|
160
|
+
return `**Registered Friends (${friends.length})**\n\n${lines.join('\n')}`;
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tinyclaw/plugin-channel-friends",
|
|
3
|
+
"version": "1.0.1-dev.3004a38",
|
|
4
|
+
"description": "Friends web chat channel plugin for Tiny Claw",
|
|
5
|
+
"license": "GPL-3.0",
|
|
6
|
+
"author": "Waren Gonzaga",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/warengonzaga/tinyclaw.git",
|
|
19
|
+
"directory": "plugins/channel/plugin-channel-friends"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/warengonzaga/tinyclaw/tree/main/plugins/channel/plugin-channel-friends#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/warengonzaga/tinyclaw/issues"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"tinyclaw",
|
|
27
|
+
"plugin",
|
|
28
|
+
"channel",
|
|
29
|
+
"friends",
|
|
30
|
+
"chat"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.json"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@tinyclaw/logger": "workspace:*",
|
|
37
|
+
"@tinyclaw/types": "workspace:*"
|
|
38
|
+
}
|
|
39
|
+
}
|