@firstperson/firstperson 2026.1.33
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/.claude/settings.local.json +16 -0
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/index.ts +18 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +56 -0
- package/src/channel.test.ts +650 -0
- package/src/channel.ts +742 -0
- package/src/config-schema.test.ts +81 -0
- package/src/config-schema.ts +13 -0
- package/src/relay-client.test.ts +452 -0
- package/src/relay-client.ts +266 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +32 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
|
|
3
|
+
export interface Logger {
|
|
4
|
+
info: (msg: string) => void;
|
|
5
|
+
warn: (msg: string) => void;
|
|
6
|
+
error: (msg: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface IncomingMessage {
|
|
10
|
+
messageId: string;
|
|
11
|
+
deviceId: string;
|
|
12
|
+
text: string;
|
|
13
|
+
timestamp?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RelayConnectionParams {
|
|
17
|
+
relayUrl: string;
|
|
18
|
+
token: string;
|
|
19
|
+
accountId: string;
|
|
20
|
+
abortSignal: AbortSignal;
|
|
21
|
+
log?: Logger;
|
|
22
|
+
onConnected: () => void;
|
|
23
|
+
onDisconnected: (error?: Error) => void;
|
|
24
|
+
onMessage: (message: IncomingMessage) => Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SendMessageResult {
|
|
28
|
+
messageId: string;
|
|
29
|
+
chatId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Store active connection and pending reply callbacks
|
|
33
|
+
let activeWs: WebSocket | null = null;
|
|
34
|
+
let activeLogger: Logger | null = null;
|
|
35
|
+
const pendingReplies = new Map<
|
|
36
|
+
string,
|
|
37
|
+
{ resolve: (result: SendMessageResult) => void; reject: (err: Error) => void; to: string }
|
|
38
|
+
>();
|
|
39
|
+
|
|
40
|
+
function log(level: "info" | "warn" | "error", msg: string): void {
|
|
41
|
+
if (activeLogger) {
|
|
42
|
+
activeLogger[level](msg);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function sendTextMessage(params: {
|
|
47
|
+
relayUrl: string;
|
|
48
|
+
token: string;
|
|
49
|
+
to: string;
|
|
50
|
+
text: string;
|
|
51
|
+
replyTo?: string;
|
|
52
|
+
}): Promise<SendMessageResult> {
|
|
53
|
+
const { to, text, replyTo } = params;
|
|
54
|
+
|
|
55
|
+
// Use active connection if available
|
|
56
|
+
if (activeWs && activeWs.readyState === WebSocket.OPEN) {
|
|
57
|
+
const messageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
58
|
+
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
// Store callback for when we get acknowledgment
|
|
61
|
+
pendingReplies.set(messageId, { resolve, reject, to });
|
|
62
|
+
|
|
63
|
+
activeWs!.send(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
type: "message",
|
|
66
|
+
to,
|
|
67
|
+
text,
|
|
68
|
+
messageId,
|
|
69
|
+
...(replyTo && { replyTo }),
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Timeout after 30 seconds
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
if (pendingReplies.has(messageId)) {
|
|
76
|
+
pendingReplies.delete(messageId);
|
|
77
|
+
resolve({ messageId, chatId: to }); // Assume sent if no error
|
|
78
|
+
}
|
|
79
|
+
}, 30000);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback: create temporary connection if no active connection
|
|
84
|
+
// This handles cases like pairing approval before gateway starts
|
|
85
|
+
const { relayUrl, token } = params;
|
|
86
|
+
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const wsUrl = `${relayUrl}/ws/gateway?token=${encodeURIComponent(token)}`;
|
|
89
|
+
const ws = new WebSocket(wsUrl);
|
|
90
|
+
|
|
91
|
+
const messageId = `msg_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
92
|
+
|
|
93
|
+
ws.on("open", () => {
|
|
94
|
+
ws.send(
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
type: "message",
|
|
97
|
+
to,
|
|
98
|
+
text,
|
|
99
|
+
messageId,
|
|
100
|
+
...(replyTo && { replyTo }),
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
ws.on("message", (data) => {
|
|
106
|
+
try {
|
|
107
|
+
const msg = JSON.parse(data.toString());
|
|
108
|
+
if (msg.type === "message_sent" || msg.type === "ack") {
|
|
109
|
+
ws.close();
|
|
110
|
+
resolve({ messageId, chatId: to });
|
|
111
|
+
} else if (msg.type === "error") {
|
|
112
|
+
ws.close();
|
|
113
|
+
reject(new Error(msg.error ?? "Send failed"));
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// Ignore parse errors for non-JSON messages
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
ws.on("error", (err) => {
|
|
121
|
+
reject(err);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Timeout after 30 seconds
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
127
|
+
ws.close();
|
|
128
|
+
resolve({ messageId, chatId: to }); // Assume sent if no error
|
|
129
|
+
}
|
|
130
|
+
}, 30000);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function startRelayConnection(params: RelayConnectionParams): Promise<void> {
|
|
135
|
+
const { relayUrl, token, abortSignal, onConnected, onDisconnected, onMessage, log: logger } =
|
|
136
|
+
params;
|
|
137
|
+
|
|
138
|
+
// Store logger for use by sendTextMessage
|
|
139
|
+
activeLogger = logger ?? null;
|
|
140
|
+
|
|
141
|
+
const wsUrl = `${relayUrl}/ws/gateway?token=${encodeURIComponent(token)}`;
|
|
142
|
+
|
|
143
|
+
let ws: WebSocket | null = null;
|
|
144
|
+
let reconnectAttempts = 0;
|
|
145
|
+
const maxReconnectAttempts = 10;
|
|
146
|
+
const baseReconnectDelay = 1000;
|
|
147
|
+
|
|
148
|
+
const connect = () => {
|
|
149
|
+
if (abortSignal.aborted) {
|
|
150
|
+
log("info", "[relay-client] connect() called but abortSignal already aborted");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
log("info", `[relay-client] Creating WebSocket connection to: ${wsUrl.replace(/token=[^&]+/, "token=***")}`);
|
|
155
|
+
ws = new WebSocket(wsUrl);
|
|
156
|
+
|
|
157
|
+
ws.on("open", () => {
|
|
158
|
+
log("info", `[relay-client] WebSocket opened, readyState: ${ws?.readyState}`);
|
|
159
|
+
reconnectAttempts = 0;
|
|
160
|
+
activeWs = ws; // Store as active connection for sending
|
|
161
|
+
onConnected();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
ws.on("message", async (data) => {
|
|
165
|
+
try {
|
|
166
|
+
const msg = JSON.parse(data.toString());
|
|
167
|
+
log("info", `[relay-client] Received message type: ${msg.type}`);
|
|
168
|
+
|
|
169
|
+
if (msg.type === "ping") {
|
|
170
|
+
log("info", "[relay-client] Responding to ping with pong");
|
|
171
|
+
ws?.send(JSON.stringify({ type: "pong" }));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Handle send acknowledgments for replies sent through this connection
|
|
176
|
+
if ((msg.type === "message_sent" || msg.type === "ack") && msg.messageId) {
|
|
177
|
+
const pending = pendingReplies.get(msg.messageId);
|
|
178
|
+
if (pending) {
|
|
179
|
+
pending.resolve({ messageId: msg.messageId, chatId: pending.to });
|
|
180
|
+
pendingReplies.delete(msg.messageId);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Handle send errors
|
|
186
|
+
if (msg.type === "error" && msg.messageId) {
|
|
187
|
+
const pending = pendingReplies.get(msg.messageId);
|
|
188
|
+
if (pending) {
|
|
189
|
+
pending.reject(new Error(msg.error ?? "Send failed"));
|
|
190
|
+
pendingReplies.delete(msg.messageId);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Handle incoming messages
|
|
196
|
+
if (msg.type === "message" && msg.from && msg.text) {
|
|
197
|
+
await onMessage({
|
|
198
|
+
messageId: msg.messageId ?? `msg_${Date.now()}`,
|
|
199
|
+
deviceId: msg.from,
|
|
200
|
+
text: msg.text,
|
|
201
|
+
timestamp: msg.timestamp,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// Ignore parse errors
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
ws.on("close", (code, reason) => {
|
|
210
|
+
log(
|
|
211
|
+
"info",
|
|
212
|
+
`[relay-client] WebSocket closed - code: ${code} reason: ${reason?.toString() || "(none)"} wasClean: ${code === 1000}`
|
|
213
|
+
);
|
|
214
|
+
log("info", `[relay-client] abortSignal.aborted: ${abortSignal.aborted}`);
|
|
215
|
+
|
|
216
|
+
// Clear active connection and reject pending replies
|
|
217
|
+
if (activeWs === ws) {
|
|
218
|
+
activeWs = null;
|
|
219
|
+
}
|
|
220
|
+
for (const [, pending] of pendingReplies) {
|
|
221
|
+
pending.reject(new Error("Connection closed"));
|
|
222
|
+
}
|
|
223
|
+
pendingReplies.clear();
|
|
224
|
+
|
|
225
|
+
if (abortSignal.aborted) {
|
|
226
|
+
log("info", "[relay-client] Connection closed due to abort signal");
|
|
227
|
+
onDisconnected();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
reconnectAttempts++;
|
|
232
|
+
if (reconnectAttempts <= maxReconnectAttempts) {
|
|
233
|
+
const delay = Math.min(baseReconnectDelay * Math.pow(2, reconnectAttempts - 1), 30000);
|
|
234
|
+
log(
|
|
235
|
+
"info",
|
|
236
|
+
`[relay-client] Reconnecting in ${delay}ms (attempt ${reconnectAttempts} of ${maxReconnectAttempts})`
|
|
237
|
+
);
|
|
238
|
+
setTimeout(connect, delay);
|
|
239
|
+
onDisconnected(new Error(`Connection closed, reconnecting in ${delay}ms...`));
|
|
240
|
+
} else {
|
|
241
|
+
log("warn", "[relay-client] Max reconnection attempts reached");
|
|
242
|
+
onDisconnected(new Error("Max reconnection attempts reached"));
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
ws.on("error", (err) => {
|
|
247
|
+
log("error", `[relay-client] WebSocket error: ${err.message}`);
|
|
248
|
+
// Error will trigger close event
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Handle abort signal
|
|
253
|
+
abortSignal.addEventListener("abort", () => {
|
|
254
|
+
log("info", "[relay-client] Abort signal received, closing WebSocket");
|
|
255
|
+
if (activeWs === ws) {
|
|
256
|
+
activeWs = null;
|
|
257
|
+
activeLogger = null;
|
|
258
|
+
}
|
|
259
|
+
if (ws) {
|
|
260
|
+
ws.close();
|
|
261
|
+
ws = null;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
connect();
|
|
266
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setFirstPersonRuntime(r: PluginRuntime): void {
|
|
6
|
+
runtime = r;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getFirstPersonRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("First Person runtime not initialized - plugin not registered");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
|
2
|
+
|
|
3
|
+
export type FirstPersonConfig = {
|
|
4
|
+
/** If false, do not start First Person. Default: true. */
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
/** Gateway authentication token for relay connection. */
|
|
7
|
+
token?: string;
|
|
8
|
+
/** WebSocket relay URL. Default: wss://chat.firstperson.ai */
|
|
9
|
+
relayUrl?: string;
|
|
10
|
+
/** DM access policy. Default: pairing. */
|
|
11
|
+
dmPolicy?: DmPolicy;
|
|
12
|
+
/** Allowlist of approved device IDs. */
|
|
13
|
+
allowFrom?: Array<string | number>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ResolvedFirstPersonAccount = {
|
|
17
|
+
accountId: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
configured: boolean;
|
|
21
|
+
token: string | null;
|
|
22
|
+
relayUrl: string;
|
|
23
|
+
config: FirstPersonConfig;
|
|
24
|
+
tokenSource: "config" | "env" | "none";
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type CoreConfig = {
|
|
28
|
+
channels?: {
|
|
29
|
+
firstperson?: FirstPersonConfig;
|
|
30
|
+
};
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"declaration": true,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "."
|
|
12
|
+
},
|
|
13
|
+
"include": ["index.ts", "src/**/*"],
|
|
14
|
+
"exclude": ["node_modules", "dist"]
|
|
15
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: false,
|
|
6
|
+
environment: "node",
|
|
7
|
+
include: ["src/**/*.test.ts"],
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: "v8",
|
|
10
|
+
include: ["src/**/*.ts"],
|
|
11
|
+
exclude: ["src/**/*.test.ts"],
|
|
12
|
+
thresholds: {
|
|
13
|
+
lines: 70,
|
|
14
|
+
branches: 70,
|
|
15
|
+
functions: 70,
|
|
16
|
+
statements: 70,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|