@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/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
+ };