@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
package/src/channel.ts
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildChannelConfigSchema,
|
|
3
|
+
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
formatPairingApproveHint,
|
|
5
|
+
PAIRING_APPROVED_MESSAGE,
|
|
6
|
+
setAccountEnabledInConfigSection,
|
|
7
|
+
deleteAccountFromConfigSection,
|
|
8
|
+
type ChannelPlugin,
|
|
9
|
+
} from "openclaw/plugin-sdk";
|
|
10
|
+
|
|
11
|
+
import { getFirstPersonRuntime } from "./runtime.js";
|
|
12
|
+
import { FirstPersonConfigSchema } from "./config-schema.js";
|
|
13
|
+
import type { FirstPersonConfig, ResolvedFirstPersonAccount, CoreConfig } from "./types.js";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_RELAY_URL = "wss://chat.firstperson.ai";
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
id: "firstperson",
|
|
19
|
+
label: "First Person",
|
|
20
|
+
selectionLabel: "First Person (iOS)",
|
|
21
|
+
docsPath: "/channels/firstperson",
|
|
22
|
+
docsLabel: "firstperson",
|
|
23
|
+
blurb: "iOS app channel via WebSocket relay.",
|
|
24
|
+
order: 80,
|
|
25
|
+
quickstartAllowFrom: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function resolveFirstPersonAccount(params: {
|
|
29
|
+
cfg: CoreConfig;
|
|
30
|
+
accountId?: string | null;
|
|
31
|
+
}): ResolvedFirstPersonAccount {
|
|
32
|
+
const { cfg, accountId } = params;
|
|
33
|
+
const fpConfig = (cfg.channels?.firstperson ?? {}) as FirstPersonConfig;
|
|
34
|
+
const token = fpConfig.token?.trim() || process.env.FIRSTPERSON_TOKEN?.trim() || null;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
|
38
|
+
name: undefined,
|
|
39
|
+
enabled: fpConfig.enabled !== false,
|
|
40
|
+
configured: Boolean(token?.trim()),
|
|
41
|
+
token,
|
|
42
|
+
relayUrl: fpConfig.relayUrl?.trim() || DEFAULT_RELAY_URL,
|
|
43
|
+
config: fpConfig,
|
|
44
|
+
tokenSource: fpConfig.token ? "config" : process.env.FIRSTPERSON_TOKEN ? "env" : "none",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listFirstPersonAccountIds(cfg: CoreConfig): string[] {
|
|
49
|
+
const fpConfig = cfg.channels?.firstperson;
|
|
50
|
+
if (!fpConfig) return [];
|
|
51
|
+
return [DEFAULT_ACCOUNT_ID];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const firstPersonPlugin: ChannelPlugin<ResolvedFirstPersonAccount> = {
|
|
55
|
+
id: "firstperson",
|
|
56
|
+
meta,
|
|
57
|
+
|
|
58
|
+
capabilities: {
|
|
59
|
+
chatTypes: ["direct"],
|
|
60
|
+
reactions: false,
|
|
61
|
+
threads: false,
|
|
62
|
+
media: true,
|
|
63
|
+
polls: false,
|
|
64
|
+
nativeCommands: false,
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
reload: { configPrefixes: ["channels.firstperson"] },
|
|
68
|
+
|
|
69
|
+
configSchema: buildChannelConfigSchema(FirstPersonConfigSchema),
|
|
70
|
+
|
|
71
|
+
// ========== PAIRING (Native `openclaw pairing approve firstperson <code>`) ==========
|
|
72
|
+
pairing: {
|
|
73
|
+
idLabel: "deviceId",
|
|
74
|
+
normalizeAllowEntry: (entry) => entry.replace(/^firstperson:/i, "").trim().toLowerCase(),
|
|
75
|
+
notifyApproval: async ({ id }) => {
|
|
76
|
+
// Send approval notification to iOS app via relay
|
|
77
|
+
const runtime = getFirstPersonRuntime();
|
|
78
|
+
const cfg = await runtime.config.readConfigFile();
|
|
79
|
+
const account = resolveFirstPersonAccount({ cfg: cfg as CoreConfig });
|
|
80
|
+
|
|
81
|
+
if (!account.token) {
|
|
82
|
+
throw new Error("First Person token not configured - cannot send approval");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const { sendTextMessage } = await import("./relay-client.js");
|
|
86
|
+
await sendTextMessage({
|
|
87
|
+
relayUrl: account.relayUrl,
|
|
88
|
+
token: account.token,
|
|
89
|
+
to: id,
|
|
90
|
+
text: PAIRING_APPROVED_MESSAGE,
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// ========== CONFIG ADAPTER ==========
|
|
96
|
+
config: {
|
|
97
|
+
listAccountIds: (cfg) => listFirstPersonAccountIds(cfg as CoreConfig),
|
|
98
|
+
|
|
99
|
+
resolveAccount: (cfg, accountId) =>
|
|
100
|
+
resolveFirstPersonAccount({ cfg: cfg as CoreConfig, accountId }),
|
|
101
|
+
|
|
102
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
103
|
+
|
|
104
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
105
|
+
setAccountEnabledInConfigSection({
|
|
106
|
+
cfg: cfg as CoreConfig,
|
|
107
|
+
sectionKey: "firstperson",
|
|
108
|
+
accountId,
|
|
109
|
+
enabled,
|
|
110
|
+
allowTopLevel: true,
|
|
111
|
+
}),
|
|
112
|
+
|
|
113
|
+
deleteAccount: ({ cfg, accountId }) =>
|
|
114
|
+
deleteAccountFromConfigSection({
|
|
115
|
+
cfg: cfg as CoreConfig,
|
|
116
|
+
sectionKey: "firstperson",
|
|
117
|
+
accountId,
|
|
118
|
+
clearBaseFields: ["token", "relayUrl"],
|
|
119
|
+
}),
|
|
120
|
+
|
|
121
|
+
isConfigured: (account) => account.configured,
|
|
122
|
+
|
|
123
|
+
describeAccount: (account) => ({
|
|
124
|
+
accountId: account.accountId,
|
|
125
|
+
name: account.name,
|
|
126
|
+
enabled: account.enabled,
|
|
127
|
+
configured: account.configured,
|
|
128
|
+
tokenSource: account.tokenSource,
|
|
129
|
+
}),
|
|
130
|
+
|
|
131
|
+
resolveAllowFrom: ({ cfg }) => {
|
|
132
|
+
const fpConfig = (cfg as CoreConfig).channels?.firstperson;
|
|
133
|
+
return (fpConfig?.allowFrom ?? []).map((entry) => String(entry));
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
137
|
+
allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean),
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
// ========== SECURITY ==========
|
|
141
|
+
security: {
|
|
142
|
+
resolveDmPolicy: ({ account }) => ({
|
|
143
|
+
policy: account.config.dmPolicy ?? "pairing",
|
|
144
|
+
allowFrom: account.config.allowFrom ?? [],
|
|
145
|
+
policyPath: "channels.firstperson.dmPolicy",
|
|
146
|
+
allowFromPath: "channels.firstperson.allowFrom",
|
|
147
|
+
approveHint: formatPairingApproveHint("firstperson"),
|
|
148
|
+
normalizeEntry: (raw) => raw.trim().replace(/^firstperson:/i, "").trim().toLowerCase(),
|
|
149
|
+
}),
|
|
150
|
+
collectWarnings: () => [],
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
// ========== SETUP ADAPTER ==========
|
|
154
|
+
setup: {
|
|
155
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
156
|
+
|
|
157
|
+
validateInput: ({ input }) => {
|
|
158
|
+
const typedInput = input as { token?: string; relayUrl?: string; useEnv?: boolean };
|
|
159
|
+
if (typedInput.useEnv) {
|
|
160
|
+
if (!process.env.FIRSTPERSON_TOKEN?.trim()) {
|
|
161
|
+
return "FIRSTPERSON_TOKEN environment variable not set.";
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (!typedInput.token?.trim()) {
|
|
166
|
+
return "First Person requires a relay token. Set FIRSTPERSON_TOKEN or provide --token.";
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
applyAccountConfig: ({ cfg, input }) => {
|
|
172
|
+
const typedInput = input as { token?: string; relayUrl?: string; useEnv?: boolean };
|
|
173
|
+
const coreCfg = cfg as CoreConfig;
|
|
174
|
+
const fpConfig = coreCfg.channels?.firstperson ?? {};
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
...coreCfg,
|
|
178
|
+
channels: {
|
|
179
|
+
...coreCfg.channels,
|
|
180
|
+
firstperson: {
|
|
181
|
+
...fpConfig,
|
|
182
|
+
enabled: true,
|
|
183
|
+
// Only write token to config if not using env var
|
|
184
|
+
...(typedInput.useEnv ? {} : typedInput.token ? { token: typedInput.token } : {}),
|
|
185
|
+
...(typedInput.relayUrl ? { relayUrl: typedInput.relayUrl } : {}),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
// ========== OUTBOUND ==========
|
|
193
|
+
outbound: {
|
|
194
|
+
deliveryMode: "direct",
|
|
195
|
+
textChunkLimit: 4096,
|
|
196
|
+
|
|
197
|
+
sendText: async ({ to, text, cfg }) => {
|
|
198
|
+
const account = resolveFirstPersonAccount({ cfg: cfg as CoreConfig });
|
|
199
|
+
if (!account.token) {
|
|
200
|
+
throw new Error("First Person not configured - no token");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const { sendTextMessage } = await import("./relay-client.js");
|
|
204
|
+
const result = await sendTextMessage({
|
|
205
|
+
relayUrl: account.relayUrl,
|
|
206
|
+
token: account.token,
|
|
207
|
+
to,
|
|
208
|
+
text,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return { channel: "firstperson", messageId: result.messageId, chatId: result.chatId };
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
sendPayload: async ({ to, payload, cfg }) => {
|
|
215
|
+
const account = resolveFirstPersonAccount({ cfg: cfg as CoreConfig });
|
|
216
|
+
if (!account.token) {
|
|
217
|
+
throw new Error("First Person not configured - no token");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const { sendTextMessage } = await import("./relay-client.js");
|
|
221
|
+
const result = await sendTextMessage({
|
|
222
|
+
relayUrl: account.relayUrl,
|
|
223
|
+
token: account.token,
|
|
224
|
+
to,
|
|
225
|
+
text: payload.text ?? "",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return { channel: "firstperson", messageId: result.messageId, chatId: result.chatId };
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
// ========== STATUS ==========
|
|
233
|
+
status: {
|
|
234
|
+
defaultRuntime: {
|
|
235
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
236
|
+
running: false,
|
|
237
|
+
lastStartAt: null,
|
|
238
|
+
lastStopAt: null,
|
|
239
|
+
lastError: null,
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
collectStatusIssues: (accounts) =>
|
|
243
|
+
accounts.flatMap((account) => {
|
|
244
|
+
const issues: Array<{ channel: string; accountId: string; kind: string; message: string }> = [];
|
|
245
|
+
if (!account.configured) {
|
|
246
|
+
issues.push({
|
|
247
|
+
channel: "firstperson",
|
|
248
|
+
accountId: account.accountId,
|
|
249
|
+
kind: "config",
|
|
250
|
+
message: "First Person token not configured",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return issues;
|
|
254
|
+
}),
|
|
255
|
+
|
|
256
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
257
|
+
configured: snapshot.configured ?? false,
|
|
258
|
+
running: snapshot.running ?? false,
|
|
259
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
260
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
261
|
+
lastError: snapshot.lastError ?? null,
|
|
262
|
+
}),
|
|
263
|
+
|
|
264
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
265
|
+
accountId: account.accountId,
|
|
266
|
+
name: account.name,
|
|
267
|
+
enabled: account.enabled,
|
|
268
|
+
configured: account.configured,
|
|
269
|
+
tokenSource: account.tokenSource,
|
|
270
|
+
relayUrl: account.relayUrl,
|
|
271
|
+
running: runtime?.running ?? false,
|
|
272
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
273
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
274
|
+
lastError: runtime?.lastError ?? null,
|
|
275
|
+
}),
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
// ========== GATEWAY ==========
|
|
279
|
+
gateway: {
|
|
280
|
+
startAccount: async (ctx) => {
|
|
281
|
+
const { account, abortSignal, log, setStatus, runtime, cfg } = ctx;
|
|
282
|
+
|
|
283
|
+
if (!account.token) {
|
|
284
|
+
throw new Error("First Person token not configured");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
log?.info(`[firstperson:${account.accountId}] starting relay connection to ${account.relayUrl}`);
|
|
288
|
+
|
|
289
|
+
setStatus({
|
|
290
|
+
accountId: account.accountId,
|
|
291
|
+
running: false,
|
|
292
|
+
lastStartAt: null,
|
|
293
|
+
lastStopAt: null,
|
|
294
|
+
lastError: null,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const { startRelayConnection } = await import("./relay-client.js");
|
|
298
|
+
|
|
299
|
+
return startRelayConnection({
|
|
300
|
+
relayUrl: account.relayUrl,
|
|
301
|
+
token: account.token,
|
|
302
|
+
accountId: account.accountId,
|
|
303
|
+
abortSignal,
|
|
304
|
+
log,
|
|
305
|
+
onConnected: () => {
|
|
306
|
+
setStatus({
|
|
307
|
+
accountId: account.accountId,
|
|
308
|
+
running: true,
|
|
309
|
+
lastStartAt: new Date().toISOString(),
|
|
310
|
+
lastStopAt: null,
|
|
311
|
+
lastError: null,
|
|
312
|
+
});
|
|
313
|
+
log?.info(`[firstperson:${account.accountId}] relay connected`);
|
|
314
|
+
},
|
|
315
|
+
onDisconnected: (error) => {
|
|
316
|
+
setStatus({
|
|
317
|
+
accountId: account.accountId,
|
|
318
|
+
running: false,
|
|
319
|
+
lastStartAt: null,
|
|
320
|
+
lastStopAt: new Date().toISOString(),
|
|
321
|
+
lastError: error?.message ?? null,
|
|
322
|
+
});
|
|
323
|
+
log?.warn(`[firstperson:${account.accountId}] relay disconnected: ${error?.message ?? "unknown"}`);
|
|
324
|
+
},
|
|
325
|
+
onMessage: async (message) => {
|
|
326
|
+
log?.info(`[fp] 1. received: ${message.messageId} from ${message.deviceId}`);
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const globalRuntime = getFirstPersonRuntime();
|
|
330
|
+
const channelApi = (globalRuntime as any).channel;
|
|
331
|
+
|
|
332
|
+
if (!channelApi?.pairing) {
|
|
333
|
+
log?.error(`[fp] ERROR: runtime.channel.pairing not available`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ========== SECURITY CHECK ==========
|
|
338
|
+
const fpConfig = (cfg as CoreConfig).channels?.firstperson ?? {};
|
|
339
|
+
const dmPolicy = fpConfig.dmPolicy ?? "pairing";
|
|
340
|
+
log?.info(`[fp] 2. checking authorization (dmPolicy=${dmPolicy})`);
|
|
341
|
+
|
|
342
|
+
// Get effective allowFrom (config + pairing store)
|
|
343
|
+
const configAllowFrom = (fpConfig.allowFrom ?? [])
|
|
344
|
+
.map((v: string | number) => String(v).trim().toLowerCase())
|
|
345
|
+
.filter(Boolean);
|
|
346
|
+
const hasWildcard = configAllowFrom.includes("*");
|
|
347
|
+
const filteredConfig = configAllowFrom.filter((v: string) => v !== "*");
|
|
348
|
+
|
|
349
|
+
// Read from pairing store
|
|
350
|
+
const storeAllowFrom = await channelApi.pairing.readAllowFromStore("firstperson");
|
|
351
|
+
const effectiveEntries = Array.from(new Set([...filteredConfig, ...storeAllowFrom]));
|
|
352
|
+
|
|
353
|
+
// Check if device is allowed
|
|
354
|
+
const normalizedDeviceId = message.deviceId.trim().toLowerCase();
|
|
355
|
+
const deviceAllowed = hasWildcard || effectiveEntries.some(
|
|
356
|
+
(entry: string) => entry.toLowerCase() === normalizedDeviceId
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
log?.info(`[fp] 3. device ${message.deviceId} allowed=${deviceAllowed} (hasWildcard=${hasWildcard}, entries=${effectiveEntries.length})`);
|
|
360
|
+
|
|
361
|
+
if (!deviceAllowed) {
|
|
362
|
+
if (dmPolicy === "pairing") {
|
|
363
|
+
// Generate pairing code and send to device
|
|
364
|
+
log?.info(`[fp] 4. generating pairing code for ${message.deviceId}`);
|
|
365
|
+
const { code, created } = await channelApi.pairing.upsertPairingRequest({
|
|
366
|
+
channel: "firstperson",
|
|
367
|
+
id: message.deviceId,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (created && code) {
|
|
371
|
+
log?.info(`[fp] 5. sending pairing code ${code} to device`);
|
|
372
|
+
const { sendTextMessage } = await import("./relay-client.js");
|
|
373
|
+
await sendTextMessage({
|
|
374
|
+
relayUrl: account.relayUrl,
|
|
375
|
+
token: account.token!,
|
|
376
|
+
to: message.deviceId,
|
|
377
|
+
text: [
|
|
378
|
+
"OpenClaw: access not configured.",
|
|
379
|
+
"",
|
|
380
|
+
`Your device id: ${message.deviceId}`,
|
|
381
|
+
"",
|
|
382
|
+
`Pairing code: ${code}`,
|
|
383
|
+
"",
|
|
384
|
+
"Ask the bot owner to approve with:",
|
|
385
|
+
` openclaw pairing approve firstperson ${code}`,
|
|
386
|
+
].join("\n"),
|
|
387
|
+
});
|
|
388
|
+
log?.info(`[fp] 6. pairing code sent`);
|
|
389
|
+
} else {
|
|
390
|
+
log?.info(`[fp] 5. pairing request already exists (code=${code}, created=${created})`);
|
|
391
|
+
}
|
|
392
|
+
return; // Don't dispatch - waiting for pairing approval
|
|
393
|
+
} else if (dmPolicy === "disabled") {
|
|
394
|
+
log?.info(`[fp] 4. blocking message from unauthorized device (dmPolicy=disabled)`);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// dmPolicy === "open" with no wildcard should not reach here
|
|
398
|
+
// since we already checked hasWildcard
|
|
399
|
+
log?.warn(`[fp] 4. unexpected state: device not allowed but dmPolicy=${dmPolicy}`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
log?.info(`[fp] 4. device authorized, proceeding with dispatch`);
|
|
404
|
+
|
|
405
|
+
// ========== ROUTING & DISPATCH ==========
|
|
406
|
+
log?.info(`[fp] 5. runtime available: ${!!channelApi?.routing}`);
|
|
407
|
+
|
|
408
|
+
// Use channel.routing API if available
|
|
409
|
+
if (channelApi?.routing) {
|
|
410
|
+
// Resolve routing
|
|
411
|
+
const route = channelApi.routing.resolveAgentRoute({
|
|
412
|
+
cfg,
|
|
413
|
+
channel: "firstperson",
|
|
414
|
+
peer: { kind: "dm", id: message.deviceId },
|
|
415
|
+
});
|
|
416
|
+
log?.info(`[fp] 6. route resolved: ${route ? route.sessionKey : "NULL"}`);
|
|
417
|
+
|
|
418
|
+
if (!route) {
|
|
419
|
+
log?.warn(`[fp] no route - check bindings for ${message.deviceId}`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Session setup
|
|
424
|
+
log?.info(`[fp] 7. setting up session...`);
|
|
425
|
+
const storePath = channelApi.session.resolveStorePath((cfg as any).session?.store, {
|
|
426
|
+
agentId: route.agentId,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
let previousTimestamp = null;
|
|
430
|
+
try {
|
|
431
|
+
previousTimestamp = channelApi.session.readSessionUpdatedAt({
|
|
432
|
+
storePath,
|
|
433
|
+
sessionKey: route.sessionKey,
|
|
434
|
+
});
|
|
435
|
+
} catch {
|
|
436
|
+
// No previous session, that's fine
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Format message envelope
|
|
440
|
+
const envelopeOptions = channelApi.reply.resolveEnvelopeFormatOptions(cfg);
|
|
441
|
+
const body = channelApi.reply.formatAgentEnvelope({
|
|
442
|
+
channel: "First Person",
|
|
443
|
+
from: message.deviceId,
|
|
444
|
+
timestamp: message.timestamp ?? new Date().toISOString(),
|
|
445
|
+
previousTimestamp,
|
|
446
|
+
envelope: envelopeOptions,
|
|
447
|
+
body: message.text,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// Build inbound context
|
|
451
|
+
const ctxPayload = channelApi.reply.finalizeInboundContext({
|
|
452
|
+
Body: body,
|
|
453
|
+
RawBody: message.text,
|
|
454
|
+
CommandBody: message.text,
|
|
455
|
+
From: `firstperson:${message.deviceId}`,
|
|
456
|
+
To: `device:${message.deviceId}`,
|
|
457
|
+
SessionKey: route.sessionKey,
|
|
458
|
+
AccountId: route.accountId,
|
|
459
|
+
ChatType: "direct",
|
|
460
|
+
ConversationLabel: message.deviceId,
|
|
461
|
+
SenderName: message.deviceId,
|
|
462
|
+
SenderId: message.deviceId,
|
|
463
|
+
Provider: "firstperson",
|
|
464
|
+
Surface: "firstperson",
|
|
465
|
+
MessageSid: message.messageId,
|
|
466
|
+
Timestamp: message.timestamp ?? new Date().toISOString(),
|
|
467
|
+
CommandAuthorized: true,
|
|
468
|
+
OriginatingChannel: "firstperson",
|
|
469
|
+
OriginatingTo: `device:${message.deviceId}`,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Record session
|
|
473
|
+
await channelApi.session.recordInboundSession({
|
|
474
|
+
storePath,
|
|
475
|
+
sessionKey: route.sessionKey,
|
|
476
|
+
ctx: ctxPayload,
|
|
477
|
+
});
|
|
478
|
+
log?.info(`[fp] 8. session recorded, starting dispatch...`);
|
|
479
|
+
|
|
480
|
+
// Create dispatcher for replies
|
|
481
|
+
const { sendTextMessage } = await import("./relay-client.js");
|
|
482
|
+
const { dispatcher, replyOptions } = channelApi.reply.createReplyDispatcherWithTyping({
|
|
483
|
+
deliver: async (payload: { text?: string }) => {
|
|
484
|
+
log?.info(`[fp] 9. delivering reply: ${(payload.text ?? "").substring(0, 50)}...`);
|
|
485
|
+
await sendTextMessage({
|
|
486
|
+
relayUrl: account.relayUrl,
|
|
487
|
+
token: account.token!,
|
|
488
|
+
to: message.deviceId,
|
|
489
|
+
text: payload.text ?? "",
|
|
490
|
+
replyTo: message.messageId,
|
|
491
|
+
});
|
|
492
|
+
log?.info(`[fp] 10. reply sent`);
|
|
493
|
+
},
|
|
494
|
+
onError: (err: Error) => {
|
|
495
|
+
log?.error(`[fp] reply delivery failed: ${err.message}`);
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Dispatch to agent
|
|
500
|
+
await channelApi.reply.dispatchReplyFromConfig({
|
|
501
|
+
ctx: ctxPayload,
|
|
502
|
+
cfg,
|
|
503
|
+
dispatcher,
|
|
504
|
+
replyOptions,
|
|
505
|
+
});
|
|
506
|
+
log?.info(`[fp] 11. dispatch complete`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Fallback: agent.enqueueInbound
|
|
511
|
+
if ((globalRuntime as any).agent?.enqueueInbound) {
|
|
512
|
+
log?.info(`[fp] fallback: using agent.enqueueInbound`);
|
|
513
|
+
const { sendTextMessage } = await import("./relay-client.js");
|
|
514
|
+
await (globalRuntime as any).agent.enqueueInbound({
|
|
515
|
+
channel: "firstperson",
|
|
516
|
+
accountId: account.accountId,
|
|
517
|
+
chatType: "direct",
|
|
518
|
+
from: `firstperson:${message.deviceId}`,
|
|
519
|
+
to: `device:${message.deviceId}`,
|
|
520
|
+
body: message.text,
|
|
521
|
+
rawBody: message.text,
|
|
522
|
+
messageId: message.messageId,
|
|
523
|
+
timestamp: message.timestamp ?? new Date().toISOString(),
|
|
524
|
+
senderId: message.deviceId,
|
|
525
|
+
senderName: message.deviceId,
|
|
526
|
+
reply: async (text: string) => {
|
|
527
|
+
log?.info(`[fp] fallback reply sent`);
|
|
528
|
+
await sendTextMessage({
|
|
529
|
+
relayUrl: account.relayUrl,
|
|
530
|
+
token: account.token!,
|
|
531
|
+
to: message.deviceId,
|
|
532
|
+
text,
|
|
533
|
+
replyTo: message.messageId,
|
|
534
|
+
});
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Fallback: gateway.handleInbound
|
|
541
|
+
if ((globalRuntime as any).gateway?.handleInbound) {
|
|
542
|
+
log?.info(`[fp] fallback: using gateway.handleInbound`);
|
|
543
|
+
const { sendTextMessage } = await import("./relay-client.js");
|
|
544
|
+
await (globalRuntime as any).gateway.handleInbound({
|
|
545
|
+
channel: "firstperson",
|
|
546
|
+
accountId: account.accountId,
|
|
547
|
+
from: message.deviceId,
|
|
548
|
+
body: message.text,
|
|
549
|
+
chatType: "direct",
|
|
550
|
+
messageId: message.messageId,
|
|
551
|
+
reply: async (text: string) => {
|
|
552
|
+
log?.info(`[fp] fallback reply sent`);
|
|
553
|
+
await sendTextMessage({
|
|
554
|
+
relayUrl: account.relayUrl,
|
|
555
|
+
token: account.token!,
|
|
556
|
+
to: message.deviceId,
|
|
557
|
+
text,
|
|
558
|
+
replyTo: message.messageId,
|
|
559
|
+
});
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
log?.error(`[fp] ERROR: no dispatch method available on runtime`);
|
|
566
|
+
|
|
567
|
+
} catch (err) {
|
|
568
|
+
log?.error(`[fp] ERROR processing message: ${err}`);
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
logoutAccount: async ({ cfg }) => {
|
|
575
|
+
const coreCfg = cfg as CoreConfig;
|
|
576
|
+
const fpConfig = coreCfg.channels?.firstperson ?? {};
|
|
577
|
+
const hadToken = Boolean(fpConfig.token);
|
|
578
|
+
|
|
579
|
+
if (hadToken) {
|
|
580
|
+
const { token, ...rest } = fpConfig;
|
|
581
|
+
const runtime = getFirstPersonRuntime();
|
|
582
|
+
await runtime.config.writeConfigFile({
|
|
583
|
+
...coreCfg,
|
|
584
|
+
channels: {
|
|
585
|
+
...coreCfg.channels,
|
|
586
|
+
firstperson: rest,
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return { cleared: hadToken, loggedOut: true };
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
// ========== MESSAGING ==========
|
|
596
|
+
messaging: {
|
|
597
|
+
normalizeTarget: (target) => {
|
|
598
|
+
const trimmed = target?.trim().toLowerCase();
|
|
599
|
+
return trimmed || undefined;
|
|
600
|
+
},
|
|
601
|
+
targetResolver: {
|
|
602
|
+
looksLikeId: (id) => Boolean(id?.trim()),
|
|
603
|
+
hint: "<deviceId>",
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
// ========== DIRECTORY ==========
|
|
608
|
+
directory: {
|
|
609
|
+
self: async () => null,
|
|
610
|
+
listPeers: async ({ cfg }) => {
|
|
611
|
+
const fpConfig = (cfg as CoreConfig).channels?.firstperson;
|
|
612
|
+
const allowFrom = fpConfig?.allowFrom ?? [];
|
|
613
|
+
return allowFrom
|
|
614
|
+
.map((entry) => String(entry).trim())
|
|
615
|
+
.filter(Boolean)
|
|
616
|
+
.map((id) => ({ kind: "user" as const, id }));
|
|
617
|
+
},
|
|
618
|
+
listGroups: async () => [],
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
// ========== ONBOARDING (openclaw channels add --channel firstperson) ==========
|
|
622
|
+
onboarding: {
|
|
623
|
+
channel: "firstperson",
|
|
624
|
+
|
|
625
|
+
getStatus: async ({ cfg }) => {
|
|
626
|
+
const account = resolveFirstPersonAccount({ cfg: cfg as CoreConfig });
|
|
627
|
+
return {
|
|
628
|
+
channel: "firstperson",
|
|
629
|
+
configured: account.configured,
|
|
630
|
+
statusLines: [`First Person: ${account.configured ? "configured" : "needs token"}`],
|
|
631
|
+
selectionHint: account.configured ? "configured" : "iOS app channel",
|
|
632
|
+
quickstartScore: account.configured ? 1 : 5,
|
|
633
|
+
};
|
|
634
|
+
},
|
|
635
|
+
|
|
636
|
+
configure: async ({ cfg, prompter }) => {
|
|
637
|
+
let next = cfg as CoreConfig;
|
|
638
|
+
const existing = resolveFirstPersonAccount({ cfg: next });
|
|
639
|
+
const hasExistingToken = Boolean(existing.token);
|
|
640
|
+
const canUseEnv = Boolean(process.env.FIRSTPERSON_TOKEN?.trim());
|
|
641
|
+
|
|
642
|
+
// Show setup instructions
|
|
643
|
+
await prompter.note(
|
|
644
|
+
[
|
|
645
|
+
"1) Open the First Person iOS app",
|
|
646
|
+
"2) Go to Settings → OpenClaw Connection",
|
|
647
|
+
"3) Copy your relay token",
|
|
648
|
+
"",
|
|
649
|
+
"Tip: You can also set FIRSTPERSON_TOKEN in your env.",
|
|
650
|
+
].join("\n"),
|
|
651
|
+
"First Person relay token",
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
let token: string | null = null;
|
|
655
|
+
|
|
656
|
+
// Check for env token
|
|
657
|
+
if (canUseEnv && !existing.config.token) {
|
|
658
|
+
const keepEnv = await prompter.confirm({
|
|
659
|
+
message: "FIRSTPERSON_TOKEN detected. Use env var?",
|
|
660
|
+
initialValue: true,
|
|
661
|
+
});
|
|
662
|
+
if (!keepEnv) {
|
|
663
|
+
token = String(
|
|
664
|
+
await prompter.text({
|
|
665
|
+
message: "Enter First Person relay token",
|
|
666
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
667
|
+
}),
|
|
668
|
+
).trim();
|
|
669
|
+
}
|
|
670
|
+
} else if (hasExistingToken) {
|
|
671
|
+
const keep = await prompter.confirm({
|
|
672
|
+
message: "First Person token already configured. Keep it?",
|
|
673
|
+
initialValue: true,
|
|
674
|
+
});
|
|
675
|
+
if (!keep) {
|
|
676
|
+
token = String(
|
|
677
|
+
await prompter.text({
|
|
678
|
+
message: "Enter First Person relay token",
|
|
679
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
680
|
+
}),
|
|
681
|
+
).trim();
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
token = String(
|
|
685
|
+
await prompter.text({
|
|
686
|
+
message: "Enter First Person relay token",
|
|
687
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
688
|
+
}),
|
|
689
|
+
).trim();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Apply config
|
|
693
|
+
next = {
|
|
694
|
+
...next,
|
|
695
|
+
channels: {
|
|
696
|
+
...next.channels,
|
|
697
|
+
firstperson: {
|
|
698
|
+
...next.channels?.firstperson,
|
|
699
|
+
enabled: true,
|
|
700
|
+
dmPolicy: "pairing",
|
|
701
|
+
...(token ? { token } : {}),
|
|
702
|
+
},
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
|
|
707
|
+
},
|
|
708
|
+
|
|
709
|
+
dmPolicy: {
|
|
710
|
+
label: "First Person",
|
|
711
|
+
channel: "firstperson",
|
|
712
|
+
policyKey: "channels.firstperson.dmPolicy",
|
|
713
|
+
allowFromKey: "channels.firstperson.allowFrom",
|
|
714
|
+
getCurrent: (cfg) => (cfg as CoreConfig).channels?.firstperson?.dmPolicy ?? "pairing",
|
|
715
|
+
setPolicy: (cfg, policy) => {
|
|
716
|
+
const coreCfg = cfg as CoreConfig;
|
|
717
|
+
const allowFrom = policy === "open"
|
|
718
|
+
? [...(coreCfg.channels?.firstperson?.allowFrom ?? []), "*"].filter((v, i, a) => a.indexOf(v) === i)
|
|
719
|
+
: coreCfg.channels?.firstperson?.allowFrom;
|
|
720
|
+
return {
|
|
721
|
+
...coreCfg,
|
|
722
|
+
channels: {
|
|
723
|
+
...coreCfg.channels,
|
|
724
|
+
firstperson: {
|
|
725
|
+
...coreCfg.channels?.firstperson,
|
|
726
|
+
dmPolicy: policy,
|
|
727
|
+
...(allowFrom ? { allowFrom } : {}),
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
|
|
734
|
+
disable: (cfg) => ({
|
|
735
|
+
...cfg,
|
|
736
|
+
channels: {
|
|
737
|
+
...(cfg as CoreConfig).channels,
|
|
738
|
+
firstperson: { ...(cfg as CoreConfig).channels?.firstperson, enabled: false },
|
|
739
|
+
},
|
|
740
|
+
}),
|
|
741
|
+
},
|
|
742
|
+
};
|