Repository navigation
Expand file tree
/
Copy pathsocial.js
More file actions
4669 lines (4611 loc) · 266 KB
/
Copy pathsocial.js
File metadata and controls
4669 lines (4611 loc) · 266 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// The host half of flybot-social, written against the FlyBot API only.
//
// Model (FlyBot homes on Bot Social):
// - One home identity per FlyBot install (e.g. @danyang.home), made by the one-call
// POST /v1/onboard/flybot on first start; the returned connector token is kept in
// the credentials file (never in state.json).
// - Every local Bot is a member identity @<bot>.<home>, keys custodied by the server.
// A Bot is registered when it is invited, when it joins an invite, or at face-to-face
// join (room-only). There is no global "published" flag: access is a per-Bot GRANT
// (Sharing → Copy invite link → accept). Default deny, Chief included. Revoked on 'bot.removed'.
// A face-to-face room-only identity is revoked when the Bot leaves its last face room.
// - Friendship is Bot-to-Bot: an invite names one local Bot and the friend joins as one of
// theirs; each side sees and reaches only the Bots granted to them (the server's
// list_remote_bots, with `via` naming the local Bots allowed to talk), shown in the roster (grouped by
// home, never in bot.list()).
// - Middleware 'social': remote targets must belong to a friend home; then approval:
// Auto-send per pair (my Bot → that person, #417), the field social.autoApprove[home]
// on the local Bot; else the friend home's setting (an older home-wide Auto-send,
// config.approval.homes[home] / default). 'hold' → FlyBot approval card.
// - Inbound: each member's server inbox is polled; messages reach that member's chat
// as untrusted text from the remote Bot (no owner approval). Outbound drafts the
// server holds (pending_approval) show in Friends' Bots until Approve / Reject.
// - Files: a Bot (message_friend_bot files) or the owner (composer) attaches files; the
// held send's approval card lists names and sizes, and only after approval are the
// bytes uploaded (hash re-checked) and sent as signed refs. Incoming files show as
// chips; get_friend_file saves one (hash verified) and hands it over as untrusted.
// - Backward compat: without a home, config.botId + token use the per-bot identity
// (start_conversation / send_message) as before.
import { createHash } from 'node:crypto'
import { chmodSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, extname, isAbsolute, join, sep } from 'node:path'
import { createSocialApi, untrustedText, mapPool, GRANT_CONCURRENCY, grantPersonView, memberHandleOf, personHomeOf } from './mcp-client.js'
import { lookFromServer, lookToServer, lookFingerprint, shownLook } from './look.js'
import { createPresence } from './presence.js'
import {
createFaceRoomApi,
isJoinCode,
mapFaceError,
mapRegisterError,
renderFaceEvent,
faceEventKey,
rememberEvent,
FaceRoomError,
alreadyLeftFace,
roomOnlyForJoin,
faceMemberLabel,
viewMe,
faceOutbound,
cleanFaceName,
platformJoinText,
leaveNoticeCoveredByClose,
humanFaceWho,
faceRoomTitle,
parseFaceHuman,
cleanFaceRole,
parseFaceInviteLink,
FACE_INVITE_TICKET,
FACE_INVITE_FEATURE,
FACE_INVITE_UNSUPPORTED,
LOCAL_INVITE_COPY,
localInviteHandle,
invalidInviteLink,
} from './face-rooms.js'
import {
hideRemoteInSettings,
forgetHiddenIfGone,
filterHiddenHomes,
presentRemoteHandles,
isHomeLevelHandle,
isHiddenRemote,
revealHiddenIfPresent,
WAITING_OTHERS,
REJECT_NOTE,
} from './hidden-remotes.js'
export const NS = 'social'
const REMOTE_TTL_MS = 30_000
// The `by` of a refusal from the core owner policy (core/src/host/reach.js POLICY): no grant for that sender.
const OWNER_POLICY = 'owner-policy'
// Empty / missing / "@" stay empty. Prefixing '@' before the body is stripped
// used to persist the literal "@" as a face-room `as` handle (#122).
const norm = (handle) => {
const raw = typeof handle === 'object' && handle !== null
? (handle.handle ?? handle.home ?? handle.member ?? '')
: handle
const body = String(raw ?? '').trim().replace(/^@+/, '').toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 120)
return body ? `@${body}` : ''
}
const MAX_FILES = 5
const DEFAULT_MAX_FILE_BYTES = 20 * 1024 * 1024
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
const MIME = { '.txt': 'text/plain', '.md': 'text/markdown', '.csv': 'text/csv', '.tsv': 'text/tab-separated-values', '.json': 'application/json', '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }
export const mimeOf = name => MIME[extname(String(name)).toLowerCase()] ?? 'application/octet-stream'
const TEXTUAL = new Set(['text/plain', 'text/markdown', 'text/csv', 'text/tab-separated-values', 'application/json'])
const sizeLabel = n => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`)
// Remote strings that reach model-visible text: one line, no brackets or markup, capped.
export const cleanText = (value, fallback = '') => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) || fallback
// Owner-only draft body: keep punctuation the friend will see; strip controls and cap.
export const draftText = (value) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 800)
// Owner cards never show the FLYBOT-FACE-HUMAN wire envelope (#159).
export function approvalDraftText(value) {
const raw = draftText(value)
const human = parseFaceHuman(raw)
const body = human ? draftText(human.text) : raw
return body.replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, '').trim()
}
// Bot Social list_approvals card → the Friends' Bots queue (outbound drafts only).
const looksLikeBotId = (value) => /^bot_[a-z0-9]+$/i.test(String(value ?? '').trim())
// Why Bot Social held a send, as the DLP categories it named (`dlp.commitment` → `commitment`). The plain
// "all outbound needs approval" reason is not a finding and is left out. Only known-shaped names pass: the card
// maps them to words, and nothing else of the server's text reaches the screen (#371).
export function holdReasons(item) {
const names = []
for (const reason of Array.isArray(item?.reasons) ? item.reasons : []) {
const match = /^dlp\.([a-z_]+(?:\.[a-z_]+)?)$/.exec(String(reason))
if (match && match[1] !== 'blocked' && !names.includes(match[1])) names.push(match[1])
}
for (const flag of Array.isArray(item?.risk_flags) ? item.risk_flags : []) {
const category = typeof flag?.category === 'string' && /^[a-z_]+(?:\.[a-z_]+)?$/.test(flag.category) ? flag.category : ''
if (category && !names.includes(category)) names.push(category)
}
return names.slice(0, 8)
}
// The one hold the outbound paths may clear by itself (#737, #766, #865). Bot Social says why it held a
// line in `hold_reason`, and `blanket_outbound` — its own "all outbound needs approval" rule — is the
// only value they clear. Every other value is a finding or an owner setting (`risk`, `attachment`,
// `policy`, the face-room Auto-send switch): the line waits for the owner on its card. The field wins
// when it and `reasons` disagree.
// A server that sends no field keeps the old allow-list, so a reason this build does not know, another
// DLP category or a risk flag still holds the line (#766); an absent field is not "not blanket", or
// every hold on an older server would strand.
const BLANKET_HOLD = 'mvp.all_outbound_requires_approval'
// The two sentences turns.js `remoteFailureNotice` sends (#1068). A shipped plugin cannot import
// core, so they are repeated here; the social test asserts the lists stay equal. Only this exact
// text on `via: 'turn-failed'` skips the reply hold. A lookalike is a normal reply.
export const TURN_FAILURE_NOTICES = Object.freeze([
'Could not reply: this Bot has no model set up yet.',
'Could not reply: this Bot could not answer right now.',
])
export function isTurnFailureNotice(msg) {
if (msg?.via !== 'turn-failed' || msg?.meta?.notice !== 'turn-failed') return false
return TURN_FAILURE_NOTICES.includes(String(msg.text ?? '').trim())
}
export function holdIsBlanket(item) {
const reason = typeof item?.hold_reason === 'string' ? item.hold_reason : ''
if (reason) return reason === 'blanket_outbound'
const reasons = Array.isArray(item?.reasons) ? item.reasons : []
const flags = Array.isArray(item?.risk_flags) ? item.risk_flags : []
return reasons.length > 0 && reasons.every(entry => String(entry) === BLANKET_HOLD) && flags.length === 0
}
// Instruction-override half of Bot Social's injection detector. The other half (`token`, `password`,
// `扮演`) matches ordinary group chat, so a room line held only for those words is not a finding:
// both homes already seated the Bots, and that room is the consent (#1113, #1118). A direct message
// never uses this.
const ROOM_INSTRUCTION_OVERRIDE = [
/ignore (all |any )?(previous|prior|above) (instructions|prompts)/i,
/忽略(之前|以上|前面|先前)的?(所有)?(指令|提示|设定)/,
/(system prompt|系统提示|developer message)/i,
/\[(SYSTEM|ADMIN|PLATFORM)\]|<\/?system>/i,
/(send|forward|reveal|leak) (me )?(all|every|your owner'?s?)/i,
]
export function roomPolicyClears(item) {
if (item?.hold_reason !== 'policy') return false
const flags = Array.isArray(item?.risk_flags) ? item.risk_flags : []
if (flags.length > 0) return false
const reasons = Array.isArray(item?.reasons) ? item.reasons : []
if (!reasons.every(entry => String(entry) === BLANKET_HOLD)) return false
const payload = item?.message?.payload
const text = typeof payload === 'string' ? payload : (typeof payload?.text === 'string' ? payload.text : '')
return !ROOM_INSTRUCTION_OVERRIDE.some(pattern => pattern.test(text))
}
// #457: the face-room path clears one more hold than the blanket rule. Bot Social writes
// `face_room_auto_send_off` for a face-room line when nothing but the room's own Auto-send switch is
// holding it, and that switch is the owner's standing consent (the owner pressed it for this Bot), so a
// Bot whose switch is on has to be able to send. Only `autoSendFaceLine` reads this. `autoSendRemoteLine`
// stays on `holdIsBlanket` alone. `autoSendRoomEnvelope` clears a blanket hold on its own (#847) and an
// ordinary room `policy` hold `roomPolicyClears` accepts, with or without pair Auto-send (#1113, #1118).
// Neither outbound path clears `face_room_auto_send_off`. The field still decides: a row with no
// `hold_reason` is never cleared here, or an older server's holds would strand.
const FACE_ROOM_AUTO_SEND_OFF = 'face_room_auto_send_off'
export function holdAllowsFaceAutoSend(item) {
if (holdIsBlanket(item)) return true
return typeof item?.hold_reason === 'string' && item.hold_reason === FACE_ROOM_AUTO_SEND_OFF
}
export function approvalView(item, { localName } = {}) {
if (!item || typeof item !== 'object') return undefined
const id = String(item.approval_id ?? item.id ?? '')
if (!id) return undefined
const to = item.message?.to
const payload = item.message?.payload
const text = approvalDraftText(payload && typeof payload === 'object' ? payload.text : payload) || approvalDraftText(item.summary)
const kind = String(item.kind ?? '')
const viaComposer = item.via === 'composer'
|| item.message?.via === 'composer'
|| item.sender_kind === 'owner'
|| item.sender_kind === 'human'
|| item.from_kind === 'human'
const named = typeof localName === 'function' ? localName(item.bot_id) : ''
const fromRaw = viaComposer
? 'You'
: named
|| (typeof item.from === 'string' ? item.from : item.from?.display_name)
|| item.sender_name
|| item.bot?.display_name
const from = viaComposer ? 'You' : (cleanText(fromRaw) || (looksLikeBotId(item.bot_id) ? 'Bot' : cleanText(item.bot_id)))
return {
id,
kind,
status: String(item.status ?? 'pending'),
from,
to: to?.handle ? norm(to.handle) : undefined,
toName: cleanText(to?.display_name) || (kind === 'face_room_message' ? 'the room' : undefined),
roomId: item.room_id || item.face_room_id || item.message?.room_id,
// The Bot Social identity that wrote it (a local Bot is found by it), and why Bot Social held it (#371).
...(typeof item.bot_id === 'string' && item.bot_id ? { botId: item.bot_id } : {}),
reasons: holdReasons(item),
// Why Bot Social says it held this line (#865): kept on the view so the decision and anything that
// shows it read one field, not a second inference from `reasons`.
...(typeof item.hold_reason === 'string' && item.hold_reason ? { holdReason: item.hold_reason } : {}),
// What Auto-send may clear on its own (#766, #865): the blanket hold only. The face-room path reads
// `autoSendHold` beside it, which adds its own switch (#457); the outbound paths never do.
blanketHold: holdIsBlanket(item),
autoSendHold: holdAllowsFaceAutoSend(item),
text,
summary: cleanText(item.summary),
createdAt: item.created_at,
attachments: (item.message?.attachments ?? []).filter(file => file?.name).map(file => ({ name: safeName(file.name), size: Number(file.size) || 0 })),
}
}
// Fence markers are removed until none is left (a nested fragment cannot rebuild one).
export const stripMarkers = (text) => {
let out = String(text)
for (let previous; previous !== out;) { previous = out; out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, '') }
return out
}
const SEEN_KEPT = 1000
// Open "Share this Bot?" cards kept (#336); the oldest goes first.
const SHARE_REQUESTS_KEPT = 20
const CREDENTIAL_NAME = /(^id_(rsa|dsa|ecdsa|ed25519)|\.(pem|key|p12|pfx|kdbx|keychain)$|credential|secret|token|password|passwd|^flybot-social.*\.json$|^\.?env$|^\.netrc$|^known_hosts$|^authorized_keys$)/i
const safeName = name => String(name ?? 'file').replace(/[\u0000-\u001f\u007f/\\:]/g, '_').replace(/^\.+/, '_').slice(0, 120) || 'file'
// The text Copy puts on the clipboard for a per-Bot invite: what to do with the link, for a
// person in FlyBot or for their Grok bot.
export function inviteText({ handle, name, home, url, expiresAt }) {
const expires = expiresAt ? new Date(expiresAt).toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }) : ''
return `Add my bot ${handle}${name ? ` (${name})` : ''}${home ? ` from my FlyBot home ${home}` : ''}. ` +
"In FlyBot: open the + menu, choose Add friend, paste this link and click Accept invite. " +
"Using a Grok bot? Send it this link and say 'join this FlyBot invite'. " +
`Link: ${url} (single use${expires ? `, expires ${expires}` : ''})`
}
// A handle part from a Bot's name. A name with no ASCII letters or digits (研究员, 助手)
// gets a stable one from its id, so two such Bots never share @bot.<home>.
export const slug = (name, id = '') => {
const ascii = String(name ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32)
return ascii || `bot-${sha256(String(id || name)).slice(0, 8)}`
}
const serverKey = url => String(url ?? '').trim().replace(/\/+$/, '').toLowerCase()
// Home handle Bot Social derives from displayName (Danyang → @danyang.home).
export const homeHandleOf = (displayName) => {
const part = String(displayName ?? 'FlyBot').toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 32) || 'flybot'
return `@${part}.home`
}
function isHandleTaken(body) {
if (!body || typeof body !== 'object') return false
const code = String(body.error ?? body.code ?? body.reason ?? '')
const message = String(body.message ?? '')
return code === 'handle_taken' || /handle[_ ]taken/i.test(code) || /handle[_ ]taken/i.test(message)
}
function handleFromOnboardError(body, displayName) {
const raw = body && typeof body === 'object'
? (body.handle ?? body.home?.handle ?? body.details?.handle)
: undefined
return typeof raw === 'string' && raw.trim() ? norm(raw) : homeHandleOf(displayName)
}
export const ONBOARD_TRIES = 12
export function nextOnboardDisplayName(base, attempt) {
const name = String(base ?? 'FlyBot').trim() || 'FlyBot'
if (attempt <= 1) return name
const suffix = String(attempt)
// homeHandleOf keeps 32 alphanumeric chars; reserve space so the suffix changes the handle.
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, '')
const maxBase = Math.max(1, 32 - suffix.length)
if (sanitized.length <= maxBase) return `${name}${suffix}`
return `${sanitized.slice(0, maxBase)}${suffix}`
}
async function onboardError(response, displayName) {
const status = response.status
let body
try { body = await response.json() } catch { body = undefined }
if (status === 409 || isHandleTaken(body)) {
const handle = handleFromOnboardError(body, displayName)
return Object.assign(
new Error(`Bot Social onboarding failed: HTTP ${status} (handle ${handle} is taken). Change displayName in the flybot-social config so a new home is created, then restart.`),
{ code: 'handle_taken', handle },
)
}
const detail = typeof body?.message === 'string' && body.message.trim()
? `: ${String(body.message).replace(/\s+/g, ' ').trim().slice(0, 200)}`
: (typeof body?.error === 'string' ? `: ${body.error}` : '')
return new Error(`Bot Social onboarding failed: HTTP ${status}${detail}`)
}
// One call to the server's FlyBot onboarding; returns { token, home } to keep.
export async function onboard({ server, displayName, webhookUrl, fetch: fetchImpl = globalThis.fetch }) {
const response = await fetchImpl(`${server.replace(/\/+$/, '')}/v1/onboard/flybot`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ display_name: displayName, ...(webhookUrl ? { webhook_url: webhookUrl } : {}) }),
})
if (!response.ok) throw await onboardError(response, displayName)
const body = await response.json()
return { token: body.connector.token, home: { id: body.home.bot_id, handle: body.home.handle }, ownerId: body.owner_id, webhook: body.webhook ?? null }
}
// First start: if @<displayName>.home is taken, retry FlyBot2, FlyBot3, … rather than
// failing the plugin and asking the owner to edit YAML (#136).
export async function onboardHome({ server, displayName, webhookUrl, fetch: fetchImpl = globalThis.fetch } = {}) {
const base = String(displayName ?? 'FlyBot').trim() || 'FlyBot'
let last
for (let attempt = 1; attempt <= ONBOARD_TRIES; attempt++) {
const name = nextOnboardDisplayName(base, attempt)
try {
return { ...(await onboard({ server, displayName: name, webhookUrl, fetch: fetchImpl })), displayName: name }
} catch (error) {
last = error
if (error?.code !== 'handle_taken') throw error
}
}
throw last
}
const INVITE_CODE = /^[A-Za-z0-9_-]+$/
const stripWrapping = token => token.replace(/^[<("'`[]+/, '').replace(/[>)"'`\].,;:!?]+$/, '')
const SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i
// The link we hand out is the server's own `https://host/i/CODE`. If a server ever answers with a
// url that lacks the /i/ segment (the server only serves /i/CODE), rebuild it from that url's host
// and the code, so Copy invite link can never produce a link Add friend would have to guess (#310).
export function inviteLink(invite) {
const url = String(invite?.url ?? '')
const code = String(invite?.code ?? '')
if (!url || !code || url.includes(`/i/${code}`)) return url || undefined
try { return `${new URL(url).origin}/i/${code}` } catch { return url }
}
// What the Add friend box may hold: the server's link (https://host/i/CODE), the same without
// the scheme, a link whose /i/ went missing (host/CODE), the bare code, or the whole "Add my
// bot ... Link: https://host/i/CODE (single use ...)" text. Bot Social's join_invite takes the
// code, so that is what we send; `host` is the pasted host when there was one, so a link from
// another server can say so when it is not found (#310).
export function parseInviteInput(input) {
const raw = String(input ?? '').trim()
const tokens = raw.split(/\s+/).map(stripWrapping).filter(Boolean)
const hasInvitePath = token => /\/i\/[^/]/.test(token)
const hostLike = token => /^[^/\s]+\.[^/\s]+\/[^/\s]/.test(token) || /^localhost(:\d+)?\/[^/\s]/i.test(token)
const token = tokens.length === 1
? tokens[0]
: tokens.find(hasInvitePath) ?? tokens.find(item => SCHEME.test(item)) ?? tokens.find(hostLike) ?? ''
if (!token) return { code: raw }
const bare = token.replace(SCHEME, '').replace(/[?#].*$/, '').replace(/\/+$/, '')
if (!bare.includes('/')) return { code: INVITE_CODE.test(bare) ? bare : raw }
const [first, ...rest] = bare.split('/')
const hasHost = SCHEME.test(token) || /\./.test(first) || /^localhost(:\d+)?$/i.test(first)
const host = hasHost ? first.toLowerCase() : undefined
const path = (hasHost ? rest : [first, ...rest]).filter(Boolean)
let code
const at = path.lastIndexOf('i')
if (at >= 0 && at < path.length - 1) code = path[at + 1]
else if (path.length === 1) code = path[0]
try { if (code) code = decodeURIComponent(code) } catch { /* keep it as pasted */ }
return { code: code && INVITE_CODE.test(code) ? code : raw, ...(host ? { host } : {}) }
}
// Neutral, not an error: re-accepting an invite for a Bot this home already has (#496).
export const ALREADY_HAVE = "You already have this friend's Bot."
export function mapJoinInviteError(error, { foreignHost } = {}) {
const msg = String(error?.message ?? error ?? '')
const code = String(error?.code ?? '')
const blob = `${code} ${msg}`
if (/relationship_exists/i.test(blob)) {
// Nothing failed: this home already has the invite's Bot (#496). The join handler answers that as a
// result; this sentence is only the fallback when it cannot (no home on this server).
return Object.assign(new Error(ALREADY_HAVE), {
code: 'relationship_exists',
...(error?.peer ? { peer: error.peer } : {}),
...(error?.bot ? { bot: error.bot } : {}),
...(error?.relationshipId || error?.relationship_id
? { relationshipId: String(error.relationshipId ?? error.relationship_id) }
: {}),
})
}
if (/invite_not_found/i.test(blob)) {
return Object.assign(new Error(foreignHost
? "This invite link wasn't found on your Bot Social server. It may come from another server, or be incomplete, expired, or already used. Ask your friend for a new link."
: "This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link."), { code: 'invite_not_found' })
}
if (/invite is expired|invite_expired/i.test(blob)) {
return Object.assign(new Error('This invite has expired. Ask your friend for a new one.'), { code: 'invite_unavailable' })
}
if (/invite is revoked|invite_revoked|invite is pending/i.test(blob)) {
return Object.assign(new Error('This invite is no longer valid. Ask your friend for a new one.'), { code: 'invite_unavailable' })
}
if (/invite_unavailable|invite is used|invite_used|already used|max_uses|used_up/i.test(blob)) {
const code = /invite_unavailable/i.test(blob) ? 'invite_unavailable' : 'invite_used'
return Object.assign(new Error('This invite was already used. Ask your friend for a new one.'), { code })
}
if (/cannot join your own invite/i.test(blob)) {
return Object.assign(new Error("That's your own invite. Send it to a friend instead."), { code: 'own_invite' })
}
if (/\bblocked\b/i.test(blob) && !/dlp_blocked/i.test(blob)) {
return Object.assign(new Error("You can't add this friend."), { code: 'blocked' })
}
if (/rate_limited/i.test(blob)) {
return Object.assign(new Error('Too many tries. Wait a moment and try again.'), { code: 'rate_limited' })
}
return error
}
export const startFailureReason = (error) => String(error?.message ?? error ?? 'the plugin did not start').replace(/\s+/g, ' ').trim() || 'the plugin did not start'
export function unavailableMessage(reason) {
const text = startFailureReason(reason)
return text.startsWith('Social is not running:') ? text : `Social is not running: ${text}`
}
export function unavailableError(reason) {
return Object.assign(new Error(unavailableMessage(reason)), { code: 'bot/refused' })
}
// UI endpoints the browser calls. Registered after a failed start so the dispatcher
// never throws "Unknown FlyBot endpoint social/…" for a plugin that is enabled but down.
export const SOCIAL_UI_ENDPOINTS = [
'social/remote-bots', 'social/settings', 'social/status', 'social/overview', 'social/friends',
'social/create-invite', 'social.createInvite', 'social/join-invite', 'social/respond', 'social/send',
'social/auto-approve', 'social/auto-accept', 'social/auto-send', 'social/poll', 'social/approvals', 'social/decide-approval',
'social/attachment',
'social/face-join', 'social/face-rooms', 'social/face-leave', 'social/face-start', 'social/face-send',
'social/face-auto', 'social/face-lead',
'social/face-invite', 'social/face-invite-revoke', 'social/face-invite-preview', 'social/face-join-invite',
'social/hide-remote',
'social/access', 'social/revoke-grant',
'social/share-friends', 'social/share-with-friend',
'social/accept-notice-dismiss', 'social/ask-share-back',
]
export function registerUnavailable(service, reason) {
const flybot = typeof service.scope === 'function' ? service.scope(NS) : service
const fail = async () => { throw unavailableError(reason) }
const offs = []
for (const name of SOCIAL_UI_ENDPOINTS) {
try { offs.push(flybot.api.handle(name, fail, { read: true })) } catch { /* already registered */ }
}
return () => { for (const off of offs.splice(0).reverse()) off?.() }
}
export const readCredentials = (path) => {
try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return undefined }
}
export const writeCredentials = (path, value) => {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, JSON.stringify(value, null, 2), { mode: 0o600 })
try { chmodSync(path, 0o600) } catch { /* file systems that ignore the write mode */ }
}
export function createSocial(service, config = {}, { client: given, api: givenApi, home: givenHome, now = Date.now, setInterval: every = globalThis.setInterval, clearInterval: clear = globalThis.clearInterval, faceApi: givenFace } = {}) {
const flybot = service.scope(NS)
const api = givenApi ?? createSocialApi({
server: config.server,
token: config.token ?? process.env.FLYBOT_SOCIAL_TOKEN,
fetch: config.fetch,
client: given,
})
const client = api
const token = config.token ?? process.env.FLYBOT_SOCIAL_TOKEN
const faceApi = givenFace ?? (config.server && token ? createFaceRoomApi({ server: config.server, token, fetch: config.fetch }) : undefined)
const home = givenHome ?? config.home
const disposers = []
const warn = (...args) => console.warn('flybot-social:', ...args)
let faceAutoCache = { at: 0, on: false, known: false }
async function faceAutoApprove({ fresh = false } = {}) {
if (!config.server || !token) return false
if (!fresh && faceAutoCache.known && now() - faceAutoCache.at < 5_000) return faceAutoCache.on
try {
const me = await client.callTool('whoami')
faceAutoCache = { at: now(), on: viewMe(me)?.autoApproveFaceRooms === true, known: true }
return faceAutoCache.on
} catch (error) {
warn('face auto-approve status failed', error)
return faceAutoCache.known ? faceAutoCache.on : false
}
}
function mapFaceAutoError(error) {
if (error instanceof FaceRoomError) return error
const code = String(error?.code ?? '')
const msg = String(error?.message ?? error ?? '')
if (code === 'unknown' || /unknown tool|no tool|not found|unrecognized/i.test(msg)) {
return new Error('This Bot Social server cannot save Auto-send yet. Update Bot Social and try again.')
}
if (code === 'http_401' || code === 'unauthorized' || /401|unauthori/i.test(code + msg)) {
return new FaceRoomError('Bot Social signed you out. Reconnect in Friends\' Bots, or restart FlyBot.', 'unauthorized', { status: 401 })
}
const raw = msg.replace(/^Bot Social\s+\S+:\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 200)
if (/connector_token_rest_forbidden/.test(code + raw)) {
return new Error('Could not save Auto-send. Try again.')
}
return new Error(raw && !/^[a-z_]+$/.test(raw) ? raw : 'Could not save Auto-send. Try again.')
}
async function setFaceAutoApprove(on) {
if (!config.server || !token) throw new Error('Bot Social is not connected')
try {
const result = await confirmedCall('set_auto_approve', { auto_approve_face_rooms: Boolean(on) })
const next = viewMe(result)?.autoApproveFaceRooms === true
faceAutoCache = { at: now(), on: next, known: true }
return { autoApproveFaceRooms: next }
} catch (error) {
throw mapFaceAutoError(error)
}
}
// Auto-send in face rooms is one switch per Bot of mine (#457, owner decision: option a), kept
// here by local Bot id (`settings.faceAutoSend`), off unless the owner turns it on. A Bot whose
// switch is on has its clean room line approved by this host right after Bot Social holds it,
// as the owner's own lines are (#179); a line Bot Social flagged still waits on its card.
// Bot Social's older account-wide setting covers every Bot, so while it is on every switch reads on;
// the first per-Bot change keeps the others on here and turns the account-wide one off.
const faceAutoOf = botId => settings.faceAutoSend?.[botId] === true
async function faceAutoView({ fresh = false } = {}) {
const all = await faceAutoApprove({ fresh }).catch(() => false)
const bots = Object.fromEntries(flybot.bot.list().map(bot => [bot.id, all || faceAutoOf(bot.id)]))
return { autoApproveFaceRooms: all, bots }
}
async function setFaceAutoSend(botId, on) {
const bot = flybot.bot.list().find(entry => entry.id === botId)
if (!bot) throw new Error('Choose which of your Bots Auto-send is for.')
if (await faceAutoApprove({ fresh: true }).catch(() => false)) {
const kept = { ...(settings.faceAutoSend ?? {}) }
for (const each of flybot.bot.list()) kept[each.id] = true
await setFaceAutoApprove(false)
settings.faceAutoSend = kept
}
const next = { ...(settings.faceAutoSend ?? {}) }
if (on) next[bot.id] = true
else delete next[bot.id]
settings.faceAutoSend = next
saveSettings()
return { ...(await faceAutoView()), botId: bot.id, botName: bot.name, on: faceAutoOf(bot.id) }
}
const maxFileBytes = config.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES
const filesDir = config.filesDir ?? join(config.credentialsPath ? dirname(config.credentialsPath) : tmpdir(), 'flybot-social-files')
const ensureDir = (dir) => { mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir }
// Local files a send carries until approval: metadata only (bytes are read again,
// and their hash checked, when the approved send goes out).
// A Bot may attach only ordinary documents: an absolute path, a known document or image
// type, nowhere under a dot folder (~/.ssh, ~/.dsh, ...), not a credential file, and not
// in this plugin's own folder. Refused before any approval card exists.
const privateDirs = () => [config.credentialsPath ? dirname(config.credentialsPath) : undefined, filesDir].filter(Boolean)
async function describeFiles(paths) {
if (paths === undefined || paths === null) return []
const list = Array.isArray(paths) ? paths : [paths]
if (list.length > MAX_FILES) throw new Error(`at most ${MAX_FILES} files per message`)
const out = []
for (const path of list) {
const given = String(path)
if (!isAbsolute(given)) throw new Error(`${given}: use an absolute path`)
let file
try { file = realpathSync(given) } catch { throw new Error(`${given} does not exist`) }
const info = statSync(file)
if (!info.isFile()) throw new Error(`${file} is not a file`)
const problem = fileProblem(file)
if (problem) throw new Error(`${given} cannot be attached: ${problem}`)
if (info.size > maxFileBytes) throw new Error(`${basename(file)} is over ${sizeLabel(maxFileBytes)}`)
// Read and hashed once; the size checked is that of the bytes read.
const bytes = await readFile(file)
if (bytes.length > maxFileBytes) throw new Error(`${basename(file)} is over ${sizeLabel(maxFileBytes)}`)
out.push({ name: safeName(basename(file)), size: bytes.length, mime: mimeOf(file), sha256: sha256(bytes), path: file })
}
return out
}
function fileProblem(file) {
const parts = file.split(sep).filter(Boolean)
if (parts.some(part => part.startsWith('.'))) return 'hidden files and folders are private'
if (CREDENTIAL_NAME.test(basename(file))) return 'it looks like a credential'
for (const dir of privateDirs()) {
let real = dir
try { real = realpathSync(dir) } catch { /* not a symlink or gone: compare the path as it is */ }
if (file === real || file.startsWith(real + sep)) return 'it is a private FlyBot file'
}
if (!(extname(file).toLowerCase() in MIME)) return `only ${Object.keys(MIME).join(' ')} files`
return undefined
}
// Composer files (bytes from the browser) wait in a private outbox until approval.
function stageFiles(files) {
if (!Array.isArray(files) || files.length === 0) return []
if (files.length > MAX_FILES) throw new Error(`at most ${MAX_FILES} files per message`)
const outbox = ensureDir(join(filesDir, 'outbox'))
return files.map((file) => {
const bytes = Buffer.from(String(file?.content_base64 ?? ''), 'base64')
if (bytes.length === 0) throw new Error(`${file?.name ?? 'file'} is empty`)
if (bytes.length > maxFileBytes) throw new Error(`${file?.name ?? 'file'} is over ${sizeLabel(maxFileBytes)}`)
const name = safeName(file.name)
const hash = sha256(bytes)
const path = join(outbox, `${hash.slice(0, 16)}-${name}`)
writeFileSync(path, bytes, { mode: 0o600 })
return { name, size: bytes.length, mime: file.mime || mimeOf(name), sha256: hash, path }
})
}
// After approval: upload each file for the sending identity; the refs go in the signed message.
async function uploadAll(files, botId) {
const refs = []
for (const file of files ?? []) {
const bytes = await readFile(file.path)
if (bytes.length > maxFileBytes) throw new Error(`${file.name} is over ${sizeLabel(maxFileBytes)}`)
if (file.sha256 && sha256(bytes) !== file.sha256) throw new Error(`${file.name} changed after it was approved; send it again`)
const up = await client.callTool('upload_attachment', { ...(botId ? { bot_id: botId } : {}), name: file.name, mime: file.mime, content_base64: bytes.toString('base64') })
refs.push(up.attachment)
}
return refs
}
// Staged composer files are removed once their send is final (sent, rejected or failed).
const outboxDir = () => join(filesDir, 'outbox')
const dropStaged = (files) => {
for (const file of files ?? []) {
if (typeof file?.path === 'string' && file.path.startsWith(outboxDir() + sep)) rmSync(file.path, { force: true })
}
}
if (flybot.approvals?.onChange) {
disposers.push(flybot.approvals.onChange((approval) => {
if (approval?.status && approval.status !== 'pending') dropStaged(approval.request?.attachments)
}))
}
// ------------------------------------------------------------ settings (per friend home)
// autoAccept[home]: messages with that friend go through without the approval hold
// (default off for every new friendship). via[handle]: the local Bot whose Bot Social
// identity carries the user's own chat with that friend's Bot.
const settingsPath = config.settingsPath ?? (config.credentialsPath ? join(dirname(config.credentialsPath), 'flybot-social-settings.json') : undefined)
let settings = { autoAccept: {}, via: {}, hiddenRemotes: {}, inboundBots: {}, roomBots: {} }
try { if (settingsPath) settings = { autoAccept: {}, via: {}, hiddenRemotes: {}, inboundBots: {}, roomBots: {}, ...JSON.parse(readFileSync(settingsPath, 'utf8')) } } catch { /* none yet */ }
settings.hiddenRemotes ??= {}
settings.inboundBots ??= {}
settings.roomBots ??= {}
const saveSettings = () => { if (settingsPath) { try { writeCredentials(settingsPath, settings) } catch (error) { warn('settings not saved', error) } } }
// Unset: off. config.autoAccept: true makes it opt-out instead. New friends start in hold (#140).
// Config keys may be written with or without '@' and in any case.
const approvalHomes = Object.fromEntries(Object.entries(config.approval?.homes ?? {}).map(([key, value]) => [norm(key), value]))
const autoAcceptOf = (friendHome) => {
const key = norm(friendHome)
const stored = settings.autoAccept[key]
if (stored !== undefined) return stored === true
if (approvalHomes[key]) return approvalHomes[key] === 'auto'
return config.autoAccept === true
}
// ------------------------------------------------------------ members
const memberOf = botId => flybot.fields.get(botId, 'member')
// A Bot gets an outside identity when it is invited, when it joins an invite, when it
// already has a grant, or at face-to-face join (`forFace`, room-only). There is no
// published flag. A room-only identity is not polled, does not sign friend sends, and
// is revoked when the Bot is in no face room (Bot Social `remove_home_member`).
const hasPersonGrant = botId => flybot.bot.hasAnyGrant?.(botId) === true
const activeMember = botId => {
const member = memberOf(botId)
return member && !member.roomOnly ? member : undefined
}
const isRoomOnlyMember = botId => Boolean(memberOf(botId)?.roomOnly) && !hasPersonGrant(botId)
// One registration per Bot at a time; a taken handle retries with the Bot's id suffix;
// a Bot removed while it was registering has its new identity revoked.
const registering = new Map()
// Who this home is, read once from `whoami` (#848): the owner-level name and the home ride on every
// share envelope so the invited home names the person who invited it, not `a friend`. A home that
// answers neither keeps the old fallback, so this is additive.
let ownerIdentity
let ownerReading
// A failed read is not cached (#848 review): a whoami that answers 401 once at startup would
// otherwise leave every share envelope saying "a friend" until the process restarts. Only an
// answer is kept; callers that arrive while a read is in flight share that one read.
async function meIdentity() {
if (ownerIdentity !== undefined) return ownerIdentity
if (!client) {
ownerIdentity = { ownerName: '', home: '' }
return ownerIdentity
}
if (!ownerReading) {
ownerReading = (async () => {
try {
const me = viewMe(await client.callTool('whoami'))
ownerIdentity = { ownerName: me?.ownerName || '', home: me?.home || '' }
} catch (error) {
warn('whoami not read', error)
} finally {
ownerReading = undefined
}
})()
}
await ownerReading
return ownerIdentity ?? { ownerName: '', home: '' }
}
// The member record the host reads when it writes a share envelope (#848): `home` and `ownerName`
// are what `shareInviter` needs, next to the identity the plugin already keeps.
const memberFields = (member, { home: homeHandle = home?.handle, ownerName = '', roomOnly = false } = {}) => ({
bot_id: member.bot_id,
handle: member.handle,
...(homeHandle ? { home: norm(homeHandle) } : {}),
...(ownerName ? { ownerName } : {}),
...(roomOnly ? { roomOnly: true } : {}),
})
async function register(bot, { forFace = false, forInvite = false } = {}) {
if (!bot) return Promise.resolve(undefined)
const existing = memberOf(bot.id)
if (existing) {
// A person grant promotes a room-only identity (#863): keep the owner-level name and home the
// share envelope reads, and fill either one from whoami only when the record never had it.
if (forInvite && existing.roomOnly) {
const me = await meIdentity()
flybot.fields.set(bot.id, 'member', memberFields(existing, {
home: existing.home || home?.handle || me.home,
ownerName: existing.ownerName || me.ownerName,
}))
}
return memberOf(bot.id)
}
if (!forFace && !forInvite && !hasPersonGrant(bot.id)) return Promise.resolve(undefined)
if (!home || flybot.readOnly?.()) return Promise.resolve(undefined)
if (!registering.has(bot.id)) {
registering.set(bot.id, registerOnce(bot, { forFace: forFace && !forInvite && !hasPersonGrant(bot.id) }).finally(() => registering.delete(bot.id)))
}
return registering.get(bot.id)
}
// A member identity another local Bot already holds. The server hands back (and renames)
// an existing member when a name maps to a handle that is taken in this home, so two Bots
// whose names slug the same (any non-ASCII name once became 'bot') must not share it:
// an invite for one would befriend the other.
const heldByOther = (botId, memberBotId) => flybot.bot.list().some(other => other.id !== botId && memberOf(other.id)?.bot_id === memberBotId)
async function registerOnce(bot, { forFace = false } = {}) {
const suffixed = `${slug(bot.name, bot.id)}-${sha256(String(bot.id)).slice(0, 6)}`
let member
try {
member = await client.callTool('add_home_member', { home: home.id, name: slug(bot.name, bot.id), display_name: bot.name })
} catch (error) {
if (!/taken|exists|conflict|duplicate|in use/i.test(String(error?.message ?? error))) throw error
member = await client.callTool('add_home_member', { home: home.id, name: suffixed, display_name: bot.name })
}
if (member && heldByOther(bot.id, member.bot_id)) {
member = await client.callTool('add_home_member', { home: home.id, name: suffixed, display_name: bot.name })
if (heldByOther(bot.id, member?.bot_id)) throw new Error(`${bot.name} got another Bot's Bot Social identity`)
}
if (flybot.bot.get(bot.id) === undefined) {
await client.callTool('remove_home_member', { home: home.id, member: member.bot_id }).catch(error => warn('revoke failed', error))
return undefined
}
// Room-only: face-to-face join of a Bot that has no person-grant. A later grant
// reuses the handle and drops the flag so leave no longer revokes it.
const me = await meIdentity()
flybot.fields.set(bot.id, 'member', memberFields(member, { ownerName: me.ownerName, home: home?.handle || me.home, roomOnly: forFace }))
// Before the identity joins anything, so the other home's first members[] already carries
// the owner's look and never paints a handle-seeded one first (#182).
await syncBotLook(bot).catch(error => warn('look not synced', error))
return memberOf(bot.id)
}
// Existing identities and granted Bots at startup, once ds-bot's team is loaded.
async function registerAll() {
await flybot.ready?.()
// Identities saved before #848 carry no `home`/`ownerName`, so the share envelope would still say
// `a friend`. Read the home once and fill them in instead of re-registering every Bot.
if (!flybot.readOnly?.() && home && flybot.bot.list().some(bot => {
const member = memberOf(bot.id)
return member && (!member.home || !member.ownerName)
})) {
const me = await meIdentity()
for (const bot of flybot.bot.list()) {
const member = memberOf(bot.id)
if (!member || (member.home && member.ownerName)) continue
if (!(home?.handle || me.home) && !me.ownerName) continue
flybot.fields.set(bot.id, 'member', memberFields(member, {
home: member.home || home?.handle || me.home,
ownerName: member.ownerName || me.ownerName,
roomOnly: member.roomOnly === true,
}))
}
}
// Repair Bots saved with a shared identity: the first keeps it, the others re-register.
const owners = new Map()
const repaired = new Set()
for (const bot of flybot.bot.list()) {
const id = memberOf(bot.id)?.bot_id
if (!id) continue
if (owners.has(id)) {
warn(`${bot.name} shared ${owners.get(id).name}'s Bot Social identity; registering its own`)
flybot.fields.set(bot.id, 'member', undefined)
repaired.add(bot.id)
} else owners.set(id, bot)
}
for (const bot of flybot.bot.list()) {
const member = memberOf(bot.id)
if (!member && !hasPersonGrant(bot.id) && !repaired.has(bot.id)) continue
try { await register(bot, { forInvite: !member?.roomOnly && (Boolean(member) || hasPersonGrant(bot.id) || repaired.has(bot.id)) }) } catch (error) {
if (/401|403|token|unauthori[sz]ed/i.test(String(error?.message ?? error))) throw error
warn(`${bot.name} not registered`, error)
}
}
await syncOutgoingGrants().catch(error => warn('grants not synced', error))
void syncLocalLooks().catch(error => warn('looks not synced', error))
}
disposers.push(flybot.on('bot.created', bot => {
if (hasPersonGrant(bot.id)) void register(bot, { forInvite: true }).catch(error => warn('register failed', error))
}))
// A new person-grant promotes a room-only identity so leave does not revoke it.
disposers.push(flybot.on?.('bot.grants', bot => {
if (!bot.granted) return
const member = memberOf(bot.id)
if (member?.roomOnly) {
// Promoting out of room-only must not drop the owner-level name and home (#863): the share
// envelope reads them, and losing them left it saying "a friend" until the next restart.
void (async () => {
const me = await meIdentity()
flybot.fields.set(bot.id, 'member', memberFields(member, {
home: member.home || home?.handle || me.home,
ownerName: member.ownerName || me.ownerName,
}))
await register(bot, { forInvite: true })
})().catch(error => warn('register failed', error))
return
}
void register(bot, { forInvite: true }).catch(error => warn('register failed', error))
}) ?? (() => {}))
disposers.push(flybot.on('bot.removed', ({ bot }) => {
const member = bot?.fields?.[NS]?.member
if (home && member) void client.callTool('remove_home_member', { home: home.id, member: member.bot_id }).catch(error => warn('revoke failed', error))
}))
async function syncBotLook(bot) {
const member = memberOf(bot?.id)
if (!member?.bot_id || typeof api.updateBotProfile !== 'function') return
// The look as the owner's home draws it, a generated one included (#182).
const look = shownLook(bot)
const patch = lookToServer(look)
// The record names the Bot Social identity it was sent to: a room-only identity is released after
// its last face room and the next room registers a new one, which has no look until it is sent
// again (#182). A record from before this (look only) sends once more.
const fingerprint = `${member.bot_id}|${lookFingerprint(look)}`
const synced = settings.lookSynced?.[bot.id]
if (synced === fingerprint) return
const empty = !('avatar' in patch) && !('color' in patch)
// Nothing to clear on an identity that never got a look (an old record is taken as this identity's).
const sentHere = typeof synced === 'string' && (synced.startsWith(`${member.bot_id}|`) || synced.startsWith('{'))
if (empty && !sentHere) return
const body = empty ? { avatar: null, color: null } : patch
try {
const result = await api.updateBotProfile({ botId: member.bot_id, ...body })
if (result?.unavailable) return
} catch (error) {
if (/invalid_avatar|invalid_color|invalid_request/i.test(`${error?.code ?? ''} ${error?.message ?? ''}`)) {
settings.lookSynced = { ...(settings.lookSynced ?? {}), [bot.id]: fingerprint }
saveSettings()
return
}
throw error
}
settings.lookSynced = { ...(settings.lookSynced ?? {}), [bot.id]: fingerprint }
saveSettings()
}
async function syncLocalLooks() {
for (const bot of flybot.bot.list()) {
try { await syncBotLook(bot) } catch (error) { warn(`${bot.name} look not synced`, error) }
}
}
// ------------------------------------------------------------ remote roster
let remote = { homes: [] }
let fetchedAt = 0
// Every Bot Social lists for us, before the shared-with-us filter: a reply may go back to one (#272).
let serverRoster = []
// True once a home roster has been applied, so an empty roster ("no friends yet") still counts as
// fetched: the first friend to accept an invite arrives on a roster that was [] (#303).
let rosterFetched = false
// Bots a roster listed but we did not publish because the grant sync before them failed (fail
// closed). They stay "unseen" for the next fetch and out of the grant re-filter until a sync succeeds.
const withheldRemotes = new Set()
// Roster fetches are numbered when they ask, not when they land (#224): an answer that lands
// after a newer one (the background refresh hide-remote starts, a slow link) is dropped, so it
// cannot put back a roster the server has since changed — nor count toward forgetting a hide.
// A late grant re-filter of an older fetch must not overwrite a newer roster either, even
// when both landed in the same millisecond (#228).
let rosterAsked = 0
let rosterApplied = 0
// Handles we declined this session. Not persisted (that is the old-server local hide).
// Keeps a stale grant re-filter or a mock/lagging list_remote_bots from putting the
// Bot back before the server drops it. Cleared when the handle is absent from a
// successful home fetch, or on an explicit re-share / regranted join.
const declinedRemotes = new Set()
function unhideDeclined(handles) {
for (const handle of handles ?? []) {
const key = norm(handle)
if (key && key !== '@') declinedRemotes.delete(key)
}
}
function forgetDeclinedIfGone(homes) {
if (!declinedRemotes.size) return
const present = new Set(presentRemoteHandles(homes))
for (const key of [...declinedRemotes]) {
if (!present.has(key)) declinedRemotes.delete(key)
}
}
function rememberInbound(handle) {
const key = norm(handle)
if (!key || key === '@') return
settings.inboundBots ??= {}
if (settings.inboundBots[key]) return
settings.inboundBots[key] = true
saveSettings()
}
// Relationships this home joined (talk-through side), by id (#383).
function rememberJoined(relationshipId) {
const rid = String(relationshipId ?? '')
if (!rid) return
settings.joinedRelationships ??= {}
if (settings.joinedRelationships[rid]) return
settings.joinedRelationships[rid] = true
saveSettings()
}
function markRoomRemote(handle, faceId) {
const key = norm(handle)
const room = String(faceId ?? '')
if (!key || key === '@' || !room) return
const peerHome = (() => {
const parts = key.slice(1).split('.')
return parts.length > 2 ? `@${parts.slice(1).join('.')}` : key
})()
if (settings.inboundBots?.[key] || settings.inboundBots?.[peerHome]) return
// Already a normal friend in the roster — do not reclassify as room-only.
if (listedRemoteBots().some(bot => bot.handle === key || bot.home === key || bot.home === peerHome)) return
settings.roomBots ??= {}
const rec = settings.roomBots[key] ?? { rooms: {} }
if (rec.rooms?.[room]) return
settings.roomBots[key] = { rooms: { ...rec.rooms, [room]: true } }
saveSettings()
}
function dropRoomRemotes(faceId) {
const room = String(faceId ?? '')
if (!room || !settings.roomBots) return
let changed = false
for (const handle of Object.keys(settings.roomBots)) {
if (!settings.roomBots[handle]?.rooms?.[room]) continue
const rooms = { ...settings.roomBots[handle].rooms }
delete rooms[room]
// Keep the handle so a server leak after leave does not put the Bot back in Friends' Bots.
settings.roomBots[handle] = { rooms, leftover: true }
changed = true
}
if (changed) saveSettings()
}
function outgoingPeerHandles() {
const keys = new Set()
if (typeof flybot.grants?.of !== 'function') return keys
for (const bot of flybot.bot.list()) {
for (const grant of flybot.grants.of(bot.id) ?? []) {
if (grant.roomId) continue
const member = memberHandleOf(grant.member || grant.grantee)
if (!member || isHomeLevelHandle(member)) continue
keys.add(member)
}
}
return keys
}
// One-time upgrade: remotes already on the server roster are treated as inbound so
// reciprocal friends are not dropped when inboundBots was empty (Codex P1). Skip
// reverse-friends of invites we created and face-room-only remotes (#138 / #161).
function backfillInboundFromRoster(mappedHomes) {
if (settings.inboundBackfilled) return
settings.inboundBots ??= {}
const outgoing = outgoingPeerHandles()
for (const bot of (mappedHomes ?? []).flatMap(entry => entry.bots ?? [])) {
const key = norm(bot.handle)
if (!key || key === '@') continue
if (outgoing.has(key)) continue
// A reply-only Bot joined our invite; seeding it inbound would show a friend row (#1054).
if (bot.replyOnly === true) continue
if (settings.roomBots?.[key]) continue
settings.inboundBots[key] = true
}
settings.inboundBackfilled = true
saveSettings()
}
function keepGrantedRemote(bot) {
const key = norm(bot.handle)
if (!key) return false
if (declinedRemotes.has(key)) return false
// A Bot someone shared with us (we joined their invite) always shows, even though the
// talk-through grant on our own Bot names it as grantee (#178).