@bamdra/bamdra-user-bind 0.1.12 → 0.1.13

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/dist/index.js CHANGED
@@ -46,6 +46,11 @@ var SELF_TOOL_NAMES = [
46
46
  "bamdra_user_bind_update_my_profile",
47
47
  "bamdra_user_bind_refresh_my_binding"
48
48
  ];
49
+ var SELF_TOOL_ALIASES = [
50
+ "user_bind_get_my_profile",
51
+ "user_bind_update_my_profile",
52
+ "user_bind_refresh_my_binding"
53
+ ];
49
54
  var ADMIN_TOOL_NAMES = [
50
55
  "bamdra_user_bind_admin_query",
51
56
  "bamdra_user_bind_admin_edit",
@@ -63,6 +68,17 @@ var REQUIRED_FEISHU_IDENTITY_SCOPES = [
63
68
  "contact:user.employee_id:readonly",
64
69
  "contact:user.base:readonly"
65
70
  ];
71
+ var CHANNELS_WITH_NATIVE_STABLE_IDS = /* @__PURE__ */ new Set([
72
+ "telegram",
73
+ "whatsapp",
74
+ "discord",
75
+ "googlechat",
76
+ "slack",
77
+ "mattermost",
78
+ "signal",
79
+ "imessage",
80
+ "msteams"
81
+ ]);
66
82
  function logUserBindEvent(event, details = {}) {
67
83
  try {
68
84
  console.info("[bamdra-user-bind]", event, JSON.stringify(details));
@@ -130,6 +146,7 @@ var UserBindStore = class {
130
146
  `);
131
147
  this.ensureColumn(TABLES.profiles, "timezone", "TEXT");
132
148
  this.ensureColumn(TABLES.profiles, "notes", "TEXT");
149
+ this.migrateChannelScopedUserIds();
133
150
  }
134
151
  db;
135
152
  markdownSyncing = /* @__PURE__ */ new Set();
@@ -156,6 +173,22 @@ var UserBindStore = class {
156
173
  source: row.source
157
174
  };
158
175
  }
176
+ reconcileProvisionalIdentity(channelType, openId, stableUserId) {
177
+ if (!openId) {
178
+ return null;
179
+ }
180
+ const provisionalUserId = createProvisionalUserId(channelType, openId);
181
+ const scopedStableUserId = scopeUserId(channelType, stableUserId);
182
+ if (provisionalUserId === scopedStableUserId) {
183
+ return this.getProfile(scopedStableUserId);
184
+ }
185
+ const provisional = this.getProfile(provisionalUserId);
186
+ const stable = this.getProfile(scopedStableUserId);
187
+ if (!provisional || !stable) {
188
+ return stable;
189
+ }
190
+ return this.mergeUsers(provisionalUserId, scopedStableUserId);
191
+ }
159
192
  listIssues() {
160
193
  return this.db.prepare(`
161
194
  SELECT id, kind, user_id, details, status, updated_at
@@ -190,22 +223,21 @@ var UserBindStore = class {
190
223
  );
191
224
  }
192
225
  upsertIdentity(args) {
193
- if (!this.markdownSyncing.has(args.userId)) {
194
- this.syncMarkdownToStore(args.userId);
195
- }
226
+ const scopedUserId = scopeUserId(args.channelType, args.userId);
196
227
  const now = (/* @__PURE__ */ new Date()).toISOString();
197
- const current = this.getProfileFromDatabase(args.userId);
228
+ const current = this.getProfileFromDatabase(scopedUserId);
229
+ const externalUserId = args.openId ?? getExternalUserId(args.channelType, args.userId);
198
230
  const next = {
199
- userId: args.userId,
231
+ userId: scopedUserId,
200
232
  name: args.profilePatch.name ?? current?.name ?? null,
201
233
  gender: args.profilePatch.gender ?? current?.gender ?? null,
202
234
  email: args.profilePatch.email ?? current?.email ?? null,
203
235
  avatar: args.profilePatch.avatar ?? current?.avatar ?? null,
204
- nickname: args.profilePatch.nickname ?? current?.nickname ?? "\u8001\u677F",
205
- preferences: args.profilePatch.preferences ?? current?.preferences ?? "\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206",
236
+ nickname: args.profilePatch.nickname ?? current?.nickname ?? null,
237
+ preferences: args.profilePatch.preferences ?? current?.preferences ?? null,
206
238
  personality: args.profilePatch.personality ?? current?.personality ?? null,
207
239
  role: args.profilePatch.role ?? current?.role ?? null,
208
- timezone: args.profilePatch.timezone ?? current?.timezone ?? "Asia/Shanghai",
240
+ timezone: args.profilePatch.timezone ?? current?.timezone ?? getServerTimezone(),
209
241
  notes: args.profilePatch.notes ?? current?.notes ?? defaultProfileNotes(),
210
242
  visibility: args.profilePatch.visibility ?? current?.visibility ?? "private",
211
243
  source: args.source,
@@ -257,10 +289,10 @@ var UserBindStore = class {
257
289
  updated_at = excluded.updated_at
258
290
  `).run(
259
291
  bindingId,
260
- args.userId,
292
+ scopedUserId,
261
293
  args.channelType,
262
294
  args.openId,
263
- args.userId,
295
+ externalUserId,
264
296
  null,
265
297
  args.source,
266
298
  now
@@ -346,8 +378,6 @@ var UserBindStore = class {
346
378
  }
347
379
  const current = this.getProfileFromDatabase(userId);
348
380
  const parsed = parseProfileMarkdown((0, import_node_fs.readFileSync)(markdownPath, "utf8"));
349
- const markdownMtime = (0, import_node_fs.statSync)(markdownPath).mtime.toISOString();
350
- const dbTime = current?.updatedAt ?? (/* @__PURE__ */ new Date(0)).toISOString();
351
381
  const patch = {
352
382
  ...parsed.profilePatch,
353
383
  notes: parsed.notes
@@ -357,7 +387,7 @@ var UserBindStore = class {
357
387
  userId,
358
388
  channelType: "manual",
359
389
  openId: null,
360
- source: "markdown-profile",
390
+ source: parsed.source ?? "markdown-profile",
361
391
  profilePatch: {
362
392
  ...patch,
363
393
  visibility: patch.visibility ?? "private"
@@ -365,14 +395,43 @@ var UserBindStore = class {
365
395
  });
366
396
  return;
367
397
  }
368
- if (markdownMtime <= dbTime && !hasProfileDifference(current, patch)) {
398
+ const markdownMtime = (0, import_node_fs.statSync)(markdownPath).mtime.toISOString();
399
+ const markdownHash = computeProfilePayloadHash({
400
+ name: patch.name ?? null,
401
+ nickname: patch.nickname ?? null,
402
+ timezone: patch.timezone ?? null,
403
+ preferences: patch.preferences ?? null,
404
+ personality: patch.personality ?? null,
405
+ role: patch.role ?? null,
406
+ visibility: patch.visibility ?? current.visibility
407
+ }, patch.notes ?? null);
408
+ const currentHash = computeProfilePayloadHash({
409
+ name: current.name,
410
+ nickname: current.nickname,
411
+ timezone: current.timezone,
412
+ preferences: current.preferences,
413
+ personality: current.personality,
414
+ role: current.role,
415
+ visibility: current.visibility
416
+ }, current.notes);
417
+ if (markdownHash === currentHash) {
418
+ return;
419
+ }
420
+ if (parsed.syncHash && parsed.syncHash === markdownHash) {
421
+ return;
422
+ }
423
+ const dbTime = current.updatedAt;
424
+ if (parsed.updatedAt && parsed.updatedAt <= dbTime && markdownMtime <= dbTime) {
425
+ return;
426
+ }
427
+ if (!parsed.syncHash && markdownMtime <= dbTime) {
369
428
  return;
370
429
  }
371
430
  this.upsertIdentity({
372
431
  userId,
373
432
  channelType: "manual",
374
433
  openId: null,
375
- source: "markdown-profile",
434
+ source: parsed.source ?? "markdown-profile",
376
435
  profilePatch: {
377
436
  ...current,
378
437
  ...patch
@@ -408,6 +467,94 @@ var UserBindStore = class {
408
467
  }
409
468
  this.db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`);
410
469
  }
470
+ migrateChannelScopedUserIds() {
471
+ const rows = this.db.prepare(`
472
+ SELECT DISTINCT user_id, channel_type
473
+ FROM ${TABLES.bindings}
474
+ WHERE user_id IS NOT NULL AND channel_type IS NOT NULL
475
+ `).all();
476
+ const renames = rows.map((row) => {
477
+ const currentUserId = String(row.user_id ?? "");
478
+ const channelType = String(row.channel_type ?? "");
479
+ const scopedUserId = scopeUserId(channelType, currentUserId);
480
+ if (!currentUserId || !channelType || scopedUserId === currentUserId) {
481
+ return null;
482
+ }
483
+ return { currentUserId, scopedUserId };
484
+ }).filter((item) => item != null);
485
+ if (renames.length === 0) {
486
+ return;
487
+ }
488
+ for (const { currentUserId, scopedUserId } of renames) {
489
+ this.renameUserId(currentUserId, scopedUserId);
490
+ }
491
+ this.writeExports();
492
+ }
493
+ renameUserId(fromUserId, toUserId) {
494
+ if (fromUserId === toUserId) {
495
+ return;
496
+ }
497
+ const existingTarget = this.getProfileFromDatabase(toUserId);
498
+ const sourceProfile = this.getProfileFromDatabase(fromUserId);
499
+ if (sourceProfile && !existingTarget) {
500
+ this.db.prepare(`UPDATE ${TABLES.profiles} SET user_id = ? WHERE user_id = ?`).run(toUserId, fromUserId);
501
+ } else if (sourceProfile && existingTarget) {
502
+ const merged = {
503
+ ...sourceProfile,
504
+ ...existingTarget,
505
+ userId: toUserId,
506
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
507
+ source: existingTarget.source || sourceProfile.source,
508
+ notes: joinNotes(existingTarget.notes, sourceProfile.notes)
509
+ };
510
+ this.db.prepare(`DELETE FROM ${TABLES.profiles} WHERE user_id = ?`).run(fromUserId);
511
+ this.db.prepare(`
512
+ INSERT INTO ${TABLES.profiles} (user_id, name, gender, email, avatar, nickname, preferences, personality, role, timezone, notes, visibility, source, updated_at)
513
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
514
+ ON CONFLICT(user_id) DO UPDATE SET
515
+ name = excluded.name,
516
+ gender = excluded.gender,
517
+ email = excluded.email,
518
+ avatar = excluded.avatar,
519
+ nickname = excluded.nickname,
520
+ preferences = excluded.preferences,
521
+ personality = excluded.personality,
522
+ role = excluded.role,
523
+ timezone = excluded.timezone,
524
+ notes = excluded.notes,
525
+ visibility = excluded.visibility,
526
+ source = excluded.source,
527
+ updated_at = excluded.updated_at
528
+ `).run(
529
+ merged.userId,
530
+ merged.name,
531
+ merged.gender,
532
+ merged.email,
533
+ merged.avatar,
534
+ merged.nickname,
535
+ merged.preferences,
536
+ merged.personality,
537
+ merged.role,
538
+ merged.timezone,
539
+ merged.notes,
540
+ merged.visibility,
541
+ merged.source,
542
+ merged.updatedAt
543
+ );
544
+ }
545
+ this.db.prepare(`UPDATE ${TABLES.bindings} SET user_id = ?, external_user_id = ?, updated_at = ? WHERE user_id = ?`).run(
546
+ toUserId,
547
+ getExternalUserId(extractChannelFromScopedUserId(toUserId) ?? "manual", toUserId),
548
+ (/* @__PURE__ */ new Date()).toISOString(),
549
+ fromUserId
550
+ );
551
+ const oldMarkdownPath = this.profileMarkdownPath(fromUserId);
552
+ const newMarkdownPath = this.profileMarkdownPath(toUserId);
553
+ if ((0, import_node_fs.existsSync)(oldMarkdownPath) && !(0, import_node_fs.existsSync)(newMarkdownPath)) {
554
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(newMarkdownPath), { recursive: true });
555
+ (0, import_node_fs.cpSync)(oldMarkdownPath, newMarkdownPath);
556
+ }
557
+ }
411
558
  };
412
559
  var UserBindRuntime = class {
413
560
  constructor(host, inputConfig) {
@@ -423,10 +570,24 @@ var UserBindRuntime = class {
423
570
  config;
424
571
  sessionCache = /* @__PURE__ */ new Map();
425
572
  bindingCache = /* @__PURE__ */ new Map();
426
- feishuScopeStatus = null;
573
+ semanticCaptureCache = /* @__PURE__ */ new Map();
574
+ semanticCaptureInFlight = /* @__PURE__ */ new Set();
575
+ pendingBindingResolutions = /* @__PURE__ */ new Map();
576
+ feishuScopeStatusCache = /* @__PURE__ */ new Map();
427
577
  bitableMirror = null;
428
578
  feishuTokenCache = /* @__PURE__ */ new Map();
579
+ pendingBindingTimer = null;
580
+ pendingBindingKickTimer = null;
581
+ pendingBindingSweepInFlight = false;
429
582
  close() {
583
+ if (this.pendingBindingTimer) {
584
+ clearInterval(this.pendingBindingTimer);
585
+ this.pendingBindingTimer = null;
586
+ }
587
+ if (this.pendingBindingKickTimer) {
588
+ clearTimeout(this.pendingBindingKickTimer);
589
+ this.pendingBindingKickTimer = null;
590
+ }
430
591
  this.store.close();
431
592
  }
432
593
  register() {
@@ -438,15 +599,24 @@ var UserBindRuntime = class {
438
599
  });
439
600
  this.registerHooks();
440
601
  this.registerTools();
602
+ this.startPendingBindingWorker();
441
603
  exposeGlobalApi(this);
442
604
  }
443
605
  getIdentityForSession(sessionId) {
444
606
  return this.sessionCache.get(sessionId) ?? null;
445
607
  }
446
608
  async resolveFromContext(context) {
447
- const parsed = parseIdentityContext(context);
609
+ const parsed = parseIdentityContext(enrichIdentityContext(context));
448
610
  if (parsed.sessionId && !parsed.channelType) {
449
- return this.sessionCache.get(parsed.sessionId) ?? null;
611
+ const cached2 = this.sessionCache.get(parsed.sessionId) ?? null;
612
+ if (cached2 && isProvisionalScopedUserId(cached2.userId) && cached2.senderOpenId) {
613
+ return this.resolveFromContext({
614
+ sessionId: parsed.sessionId,
615
+ channel: { type: cached2.channelType },
616
+ sender: { id: cached2.senderOpenId, name: cached2.senderName }
617
+ });
618
+ }
619
+ return cached2;
450
620
  }
451
621
  if (!parsed.sessionId || !parsed.channelType) {
452
622
  return null;
@@ -454,47 +624,86 @@ var UserBindRuntime = class {
454
624
  const cacheKey = `${parsed.channelType}:${parsed.openId ?? parsed.sessionId}`;
455
625
  const cached = this.bindingCache.get(cacheKey);
456
626
  if (cached && cached.expiresAt > Date.now()) {
457
- this.sessionCache.set(parsed.sessionId, cached.identity);
458
- return cached.identity;
627
+ if (parsed.openId && (isProvisionalScopedUserId(cached.identity.userId) || cached.identity.source === "synthetic-fallback")) {
628
+ const rebound = this.store.findBinding(parsed.channelType, parsed.openId);
629
+ if (!rebound || rebound.userId === cached.identity.userId) {
630
+ this.sessionCache.set(parsed.sessionId, cached.identity);
631
+ return cached.identity;
632
+ }
633
+ this.bindingCache.delete(cacheKey);
634
+ } else {
635
+ this.sessionCache.set(parsed.sessionId, cached.identity);
636
+ return cached.identity;
637
+ }
459
638
  }
460
639
  const binding = this.store.findBinding(parsed.channelType, parsed.openId);
461
640
  let userId = binding?.userId ?? null;
462
641
  let source = binding?.source ?? "local";
642
+ const provisionalUserId = parsed.openId ? createProvisionalUserId(parsed.channelType, parsed.openId) : null;
463
643
  let remoteProfilePatch = {};
464
644
  if (parsed.channelType === "feishu" && parsed.openId) {
465
- const scopeStatus = await this.ensureFeishuScopeStatus();
466
- if (scopeStatus.missingIdentityScopes.length > 0) {
467
- const details = `Missing Feishu scopes: ${scopeStatus.missingIdentityScopes.join(", ")}`;
468
- logUserBindEvent("feishu-scope-missing", {
469
- openId: parsed.openId,
470
- missingScopes: scopeStatus.missingIdentityScopes
471
- });
472
- this.store.recordIssue("feishu-scope-missing", details);
473
- }
474
645
  if (!userId) {
475
646
  const remote = await this.tryResolveFeishuUser(parsed.openId);
476
647
  if (remote?.userId) {
477
648
  userId = remote.userId;
478
649
  source = remote.source;
479
650
  remoteProfilePatch = remote.profilePatch;
651
+ } else if (remote?.source === "feishu-contact-scope-missing") {
652
+ source = remote.source;
653
+ remoteProfilePatch = remote.profilePatch;
654
+ this.enqueuePendingBindingResolution(parsed.channelType, parsed.openId, remote.source);
480
655
  } else {
481
656
  this.store.recordIssue("feishu-resolution", `Failed to resolve real user id for ${parsed.openId}`);
657
+ this.enqueuePendingBindingResolution(parsed.channelType, parsed.openId, "feishu-resolution-miss");
482
658
  }
483
659
  }
484
660
  }
661
+ const profilePatch = {
662
+ name: parsed.senderName,
663
+ ...remoteProfilePatch
664
+ };
665
+ if (!userId && parsed.openId && parsed.channelType && CHANNELS_WITH_NATIVE_STABLE_IDS.has(parsed.channelType)) {
666
+ userId = `${parsed.channelType}:${parsed.openId}`;
667
+ source = "channel-native";
668
+ }
669
+ if (!userId && provisionalUserId) {
670
+ userId = provisionalUserId;
671
+ source = "provisional-openid";
672
+ this.enqueuePendingBindingResolution(parsed.channelType, parsed.openId, source);
673
+ }
674
+ const shouldPersistIdentity = Boolean(userId);
485
675
  if (!userId) {
486
- userId = `${parsed.channelType}:${parsed.openId ?? parsed.sessionId}`;
676
+ userId = createEphemeralUserId(parsed.channelType, parsed.openId, parsed.sessionId);
487
677
  source = "synthetic-fallback";
488
678
  }
489
- const profile = this.store.upsertIdentity({
679
+ if (shouldPersistIdentity) {
680
+ this.store.getProfile(userId);
681
+ }
682
+ const profile = shouldPersistIdentity ? (() => {
683
+ let persisted = this.store.upsertIdentity({
684
+ userId,
685
+ channelType: parsed.channelType,
686
+ openId: parsed.openId,
687
+ source,
688
+ profilePatch
689
+ });
690
+ if (provisionalUserId && persisted.userId !== provisionalUserId && source !== "provisional-openid") {
691
+ const reconciled = this.store.reconcileProvisionalIdentity(parsed.channelType, parsed.openId, persisted.userId);
692
+ if (reconciled) {
693
+ persisted = reconciled;
694
+ logUserBindEvent("provisional-profile-merged", {
695
+ provisionalUserId,
696
+ stableUserId: persisted.userId,
697
+ sessionId: parsed.sessionId
698
+ });
699
+ }
700
+ }
701
+ return persisted;
702
+ })() : buildLightweightProfile({
490
703
  userId,
491
- channelType: parsed.channelType,
492
- openId: parsed.openId,
493
704
  source,
494
- profilePatch: {
495
- name: parsed.senderName,
496
- ...remoteProfilePatch
497
- }
705
+ current: null,
706
+ profilePatch
498
707
  });
499
708
  const identity = {
500
709
  sessionId: parsed.sessionId,
@@ -511,26 +720,154 @@ var UserBindRuntime = class {
511
720
  expiresAt: Date.now() + this.config.cacheTtlMs,
512
721
  identity
513
722
  });
514
- if (parsed.channelType === "feishu") {
723
+ if (shouldPersistIdentity && parsed.channelType === "feishu") {
515
724
  await this.syncFeishuMirror(identity);
516
725
  }
517
726
  return identity;
518
727
  }
728
+ startPendingBindingWorker() {
729
+ if (this.pendingBindingTimer) {
730
+ return;
731
+ }
732
+ this.pendingBindingTimer = setInterval(() => {
733
+ void this.runPendingBindingSweep();
734
+ }, 60 * 1e3);
735
+ this.pendingBindingTimer.unref?.();
736
+ }
737
+ schedulePendingBindingSweep(delayMs) {
738
+ if (this.pendingBindingKickTimer) {
739
+ return;
740
+ }
741
+ this.pendingBindingKickTimer = setTimeout(() => {
742
+ this.pendingBindingKickTimer = null;
743
+ void this.runPendingBindingSweep();
744
+ }, delayMs);
745
+ this.pendingBindingKickTimer.unref?.();
746
+ }
747
+ enqueuePendingBindingResolution(channelType, openId, reason) {
748
+ if (!openId) {
749
+ return;
750
+ }
751
+ const key = `${channelType}:${openId}`;
752
+ const current = this.pendingBindingResolutions.get(key);
753
+ if (current) {
754
+ current.reason = reason;
755
+ current.nextAttemptAt = Math.min(current.nextAttemptAt, Date.now() + 5e3);
756
+ this.pendingBindingResolutions.set(key, current);
757
+ } else {
758
+ this.pendingBindingResolutions.set(key, {
759
+ channelType,
760
+ openId,
761
+ reason,
762
+ attempts: 0,
763
+ nextAttemptAt: Date.now() + 5e3
764
+ });
765
+ }
766
+ this.schedulePendingBindingSweep(5e3);
767
+ }
768
+ async runPendingBindingSweep() {
769
+ if (this.pendingBindingSweepInFlight || this.pendingBindingResolutions.size === 0) {
770
+ return;
771
+ }
772
+ this.pendingBindingSweepInFlight = true;
773
+ try {
774
+ const now = Date.now();
775
+ for (const [key, pending] of this.pendingBindingResolutions.entries()) {
776
+ if (pending.nextAttemptAt > now) {
777
+ continue;
778
+ }
779
+ const existing = this.store.findBinding(pending.channelType, pending.openId);
780
+ if (existing && !isProvisionalScopedUserId(existing.userId)) {
781
+ this.store.upsertIdentity({
782
+ userId: existing.userId,
783
+ channelType: pending.channelType,
784
+ openId: pending.openId,
785
+ source: existing.source,
786
+ profilePatch: {}
787
+ });
788
+ this.store.reconcileProvisionalIdentity(pending.channelType, pending.openId, existing.userId);
789
+ this.dropCachedOpenIdIdentity(pending.channelType, pending.openId);
790
+ this.pendingBindingResolutions.delete(key);
791
+ logUserBindEvent("pending-binding-reconciled", {
792
+ channelType: pending.channelType,
793
+ openId: pending.openId,
794
+ userId: existing.userId
795
+ });
796
+ continue;
797
+ }
798
+ if (pending.channelType !== "feishu") {
799
+ this.pendingBindingResolutions.delete(key);
800
+ continue;
801
+ }
802
+ const remote = await this.tryResolveFeishuUser(pending.openId);
803
+ if (remote?.userId) {
804
+ this.store.upsertIdentity({
805
+ userId: remote.userId,
806
+ channelType: pending.channelType,
807
+ openId: pending.openId,
808
+ source: remote.source,
809
+ profilePatch: remote.profilePatch
810
+ });
811
+ this.store.reconcileProvisionalIdentity(pending.channelType, pending.openId, remote.userId);
812
+ this.dropCachedOpenIdIdentity(pending.channelType, pending.openId);
813
+ this.pendingBindingResolutions.delete(key);
814
+ logUserBindEvent("pending-binding-resolved", {
815
+ channelType: pending.channelType,
816
+ openId: pending.openId,
817
+ userId: remote.userId,
818
+ source: remote.source
819
+ });
820
+ continue;
821
+ }
822
+ pending.attempts += 1;
823
+ pending.nextAttemptAt = Date.now() + getPendingBindingRetryDelayMs(remote?.source ?? pending.reason, pending.attempts);
824
+ this.pendingBindingResolutions.set(key, pending);
825
+ }
826
+ } finally {
827
+ this.pendingBindingSweepInFlight = false;
828
+ }
829
+ }
830
+ dropCachedOpenIdIdentity(channelType, openId) {
831
+ const cacheKey = `${channelType}:${openId}`;
832
+ this.bindingCache.delete(cacheKey);
833
+ for (const [sessionId, identity] of this.sessionCache.entries()) {
834
+ if (identity.channelType === channelType && identity.senderOpenId === openId) {
835
+ this.sessionCache.delete(sessionId);
836
+ }
837
+ }
838
+ }
519
839
  async getMyProfile(context) {
520
840
  const identity = await this.requireCurrentIdentity(context);
521
841
  return identity.profile;
522
842
  }
523
- async updateMyProfile(context, patch) {
843
+ async updateMyProfile(context, patch, operations) {
524
844
  const identity = await this.requireCurrentIdentity(context);
525
- const updated = this.store.updateProfile(identity.userId, {
526
- ...patch,
845
+ const fallbackOperations = extractProfilePatchOperations(context && typeof context === "object" ? context : {});
846
+ const nextPatch = applyProfilePatchOperations(identity.profile, patch, operations ?? fallbackOperations);
847
+ const updated = identity.source === "synthetic-fallback" ? this.store.upsertIdentity({
848
+ userId: createStableLocalUserId(identity.channelType, identity.senderOpenId, identity.sessionId),
849
+ channelType: identity.channelType,
850
+ openId: identity.senderOpenId,
851
+ source: "self-bootstrap",
852
+ profilePatch: {
853
+ ...identity.profile,
854
+ ...nextPatch
855
+ }
856
+ }) : this.store.updateProfile(identity.userId, {
857
+ ...nextPatch,
527
858
  source: "self-update"
528
859
  });
529
860
  const nextIdentity = {
530
861
  ...identity,
862
+ userId: updated.userId,
863
+ source: updated.source,
531
864
  profile: updated
532
865
  };
533
866
  this.sessionCache.set(identity.sessionId, nextIdentity);
867
+ this.bindingCache.set(`${identity.channelType}:${identity.senderOpenId ?? identity.sessionId}`, {
868
+ expiresAt: Date.now() + this.config.cacheTtlMs,
869
+ identity: nextIdentity
870
+ });
534
871
  return updated;
535
872
  }
536
873
  async refreshMyBinding(context) {
@@ -546,6 +883,80 @@ var UserBindRuntime = class {
546
883
  }
547
884
  return refreshed;
548
885
  }
886
+ async captureProfileFromMessage(context, identity) {
887
+ const messageText = extractUserUtterance(context);
888
+ if (!messageText) {
889
+ logUserBindEvent("semantic-profile-capture-skipped-no-utterance", {
890
+ userId: identity.userId,
891
+ sessionId: identity.sessionId
892
+ });
893
+ return;
894
+ }
895
+ const fingerprint = hashId(`${identity.sessionId}:${messageText}`);
896
+ this.pruneSemanticCaptureCache();
897
+ if (this.semanticCaptureInFlight.has(fingerprint)) {
898
+ return;
899
+ }
900
+ const processedAt = this.semanticCaptureCache.get(fingerprint);
901
+ if (processedAt && processedAt > Date.now() - 12 * 60 * 60 * 1e3) {
902
+ return;
903
+ }
904
+ this.semanticCaptureInFlight.add(fingerprint);
905
+ try {
906
+ const extraction = await inferSemanticProfileExtraction(messageText, identity.profile);
907
+ if (!extraction?.shouldUpdate) {
908
+ logUserBindEvent("semantic-profile-capture-noop", {
909
+ userId: identity.userId,
910
+ sessionId: identity.sessionId,
911
+ confidence: extraction?.confidence ?? 0,
912
+ messagePreview: messageText.slice(0, 120)
913
+ });
914
+ this.semanticCaptureCache.set(fingerprint, Date.now());
915
+ return;
916
+ }
917
+ const { patch, operations } = cleanupSemanticProfilePatch(
918
+ extraction.patch,
919
+ identity.profile,
920
+ extraction.operations
921
+ );
922
+ if (Object.keys(patch).length === 0) {
923
+ this.semanticCaptureCache.set(fingerprint, Date.now());
924
+ return;
925
+ }
926
+ await this.updateMyProfile(
927
+ { sessionId: identity.sessionId },
928
+ {
929
+ ...patch,
930
+ source: "semantic-self-update"
931
+ },
932
+ operations
933
+ );
934
+ logUserBindEvent("semantic-profile-capture-success", {
935
+ userId: identity.userId,
936
+ sessionId: identity.sessionId,
937
+ fields: Object.keys(patch),
938
+ operations,
939
+ confidence: extraction.confidence
940
+ });
941
+ this.semanticCaptureCache.set(fingerprint, Date.now());
942
+ } catch (error) {
943
+ logUserBindEvent("semantic-profile-capture-failed", {
944
+ userId: identity.userId,
945
+ sessionId: identity.sessionId,
946
+ message: error instanceof Error ? error.message : String(error)
947
+ });
948
+ } finally {
949
+ this.semanticCaptureInFlight.delete(fingerprint);
950
+ }
951
+ }
952
+ pruneSemanticCaptureCache() {
953
+ const cutoff = Date.now() - 12 * 60 * 60 * 1e3;
954
+ for (const [fingerprint, ts] of this.semanticCaptureCache.entries()) {
955
+ if (ts < cutoff) {
956
+ this.semanticCaptureCache.delete(fingerprint);
957
+ }
958
+ }
959
+ }
549
960
  async adminInstruction(toolName, instruction, context) {
550
961
  const requester = await this.resolveFromContext(context);
551
962
  const agentId = getAgentIdFromContext(context);
@@ -612,18 +1023,23 @@ var UserBindRuntime = class {
612
1023
  this.host.registerHook?.(
613
1024
  ["message:received", "message:preprocessed"],
614
1025
  async (event) => {
615
- await this.resolveFromContext(event);
1026
+ const identity = await this.resolveFromContext(event);
1027
+ if (identity) {
1028
+ await this.captureProfileFromMessage(event, identity);
1029
+ }
616
1030
  },
617
1031
  {
618
1032
  name: "bamdra-user-bind-resolve",
619
1033
  description: "Resolve runtime identity from channel sender metadata"
620
1034
  }
621
1035
  );
622
- this.host.on?.("before_prompt_build", async (_event, context) => {
1036
+ this.host.on?.("before_prompt_build", async (event, context) => {
623
1037
  const identity = await this.resolveFromContext(context);
624
1038
  if (!identity) {
625
1039
  return;
626
1040
  }
1041
+ const captureContext = mergeSemanticCaptureContext(event, context);
1042
+ await this.captureProfileFromMessage(captureContext, identity);
627
1043
  return {
628
1044
  context: [
629
1045
  {
@@ -639,9 +1055,16 @@ var UserBindRuntime = class {
639
1055
  if (!registerTool) {
640
1056
  return;
641
1057
  }
1058
+ const getMyProfileExecute = async (_id, params) => asTextResult(await this.getMyProfile(params));
1059
+ const updateMyProfileExecute = async (_id, params) => asTextResult(await this.updateMyProfile(
1060
+ params,
1061
+ sanitizeProfilePatch(params),
1062
+ extractProfilePatchOperations(params)
1063
+ ));
1064
+ const refreshMyBindingExecute = async (_id, params) => asTextResult(await this.refreshMyBinding(params));
642
1065
  registerTool({
643
1066
  name: "bamdra_user_bind_get_my_profile",
644
- description: "Get the current user's bound profile",
1067
+ description: "Get the current user's bound profile before replying so nickname, timezone, and stable preferences can be used naturally in the response",
645
1068
  parameters: {
646
1069
  type: "object",
647
1070
  additionalProperties: false,
@@ -649,11 +1072,11 @@ var UserBindRuntime = class {
649
1072
  sessionId: { type: "string" }
650
1073
  }
651
1074
  },
652
- execute: async (_id, params) => asTextResult(await this.getMyProfile(params))
1075
+ execute: getMyProfileExecute
653
1076
  });
654
1077
  registerTool({
655
1078
  name: "bamdra_user_bind_update_my_profile",
656
- description: "Update the current user's own profile fields",
1079
+ description: "Immediately write the current user's stable preferences into their profile when they clearly provide them, such as nickname, communication style, timezone, role, or durable notes",
657
1080
  parameters: {
658
1081
  type: "object",
659
1082
  additionalProperties: false,
@@ -661,18 +1084,67 @@ var UserBindRuntime = class {
661
1084
  properties: {
662
1085
  sessionId: { type: "string" },
663
1086
  nickname: { type: "string" },
1087
+ nicknameOperation: { type: "string", enum: ["replace", "append", "remove"] },
664
1088
  preferences: { type: "string" },
1089
+ preferencesOperation: { type: "string", enum: ["replace", "append", "remove"] },
665
1090
  personality: { type: "string" },
1091
+ personalityOperation: { type: "string", enum: ["replace", "append", "remove"] },
666
1092
  role: { type: "string" },
1093
+ roleOperation: { type: "string", enum: ["replace", "append", "remove"] },
667
1094
  timezone: { type: "string" },
668
- notes: { type: "string" }
1095
+ timezoneOperation: { type: "string", enum: ["replace", "append", "remove"] },
1096
+ notes: { type: "string" },
1097
+ notesOperation: { type: "string", enum: ["replace", "append", "remove"] }
669
1098
  }
670
1099
  },
671
- execute: async (_id, params) => asTextResult(await this.updateMyProfile(params, sanitizeProfilePatch(params)))
1100
+ execute: updateMyProfileExecute
672
1101
  });
673
1102
  registerTool({
674
1103
  name: "bamdra_user_bind_refresh_my_binding",
675
- description: "Refresh the current user's identity binding",
1104
+ description: "Refresh the current user's identity binding when the session looks unresolved, stale, or mapped to the wrong external identity, then fetch the profile again",
1105
+ parameters: {
1106
+ type: "object",
1107
+ additionalProperties: false,
1108
+ properties: {
1109
+ sessionId: { type: "string" }
1110
+ }
1111
+ },
1112
+ execute: refreshMyBindingExecute
1113
+ });
1114
+ registerTool({
1115
+ name: SELF_TOOL_ALIASES[0],
1116
+ description: "Alias of bamdra_user_bind_get_my_profile",
1117
+ parameters: {
1118
+ type: "object",
1119
+ additionalProperties: false,
1120
+ properties: {
1121
+ sessionId: { type: "string" }
1122
+ }
1123
+ },
1124
+ execute: getMyProfileExecute
1125
+ });
1126
+ registerTool({
1127
+ name: SELF_TOOL_ALIASES[1],
1128
+ description: "Alias of bamdra_user_bind_update_my_profile",
1129
+ parameters: {
1130
+ type: "object",
1131
+ additionalProperties: false,
1132
+ required: ["sessionId"],
1133
+ properties: {
1134
+ sessionId: { type: "string" },
1135
+ nickname: { type: "string" },
1136
+ preferences: { type: "string" },
1137
+ personality: { type: "string" },
1138
+ role: { type: "string" },
1139
+ timezone: { type: "string" },
1140
+ notes: { type: "string" }
1141
+ }
1142
+ },
1143
+ execute: updateMyProfileExecute
1144
+ });
1145
+ registerTool({
1146
+ name: SELF_TOOL_ALIASES[2],
1147
+ description: "Alias of bamdra_user_bind_refresh_my_binding",
676
1148
  parameters: {
677
1149
  type: "object",
678
1150
  additionalProperties: false,
@@ -680,7 +1152,7 @@ var UserBindRuntime = class {
680
1152
  sessionId: { type: "string" }
681
1153
  }
682
1154
  },
683
- execute: async (_id, params) => asTextResult(await this.refreshMyBinding(params))
1155
+ execute: refreshMyBindingExecute
684
1156
  });
685
1157
  for (const toolName of ADMIN_TOOL_NAMES) {
686
1158
  registerTool({
@@ -714,6 +1186,7 @@ var UserBindRuntime = class {
714
1186
  logUserBindEvent("feishu-resolution-skipped", { reason: "no-feishu-accounts-configured" });
715
1187
  return null;
716
1188
  }
1189
+ const contactScopeBlockedAccounts = /* @__PURE__ */ new Set();
717
1190
  for (const account of accounts) {
718
1191
  try {
719
1192
  const token = await this.getFeishuAppAccessToken(account);
@@ -746,6 +1219,9 @@ var UserBindRuntime = class {
746
1219
  };
747
1220
  } catch (error) {
748
1221
  const message = error instanceof Error ? error.message : String(error);
1222
+ if (looksLikeFeishuContactScopeError(message)) {
1223
+ contactScopeBlockedAccounts.add(account.accountId);
1224
+ }
749
1225
  logUserBindEvent("feishu-resolution-attempt-failed", {
750
1226
  accountId: account.accountId,
751
1227
  openId,
@@ -777,15 +1253,24 @@ var UserBindRuntime = class {
777
1253
  } catch {
778
1254
  }
779
1255
  }
1256
+ if (contactScopeBlockedAccounts.size > 0) {
1257
+ return {
1258
+ userId: "",
1259
+ source: "feishu-contact-scope-missing",
1260
+ profilePatch: {
1261
+ notes: renderFeishuContactScopeGuidance([...contactScopeBlockedAccounts])
1262
+ }
1263
+ };
1264
+ }
780
1265
  logUserBindEvent("feishu-resolution-empty", { openId });
781
1266
  return null;
782
1267
  }
783
- async ensureFeishuScopeStatus() {
784
- if (this.feishuScopeStatus) {
785
- return this.feishuScopeStatus;
786
- }
787
- const accounts = readFeishuAccountsFromOpenClawConfig();
788
- for (const account of accounts) {
1268
+ async ensureFeishuScopeStatus(account) {
1269
+ if (account) {
1270
+ const cached = this.feishuScopeStatusCache.get(account.accountId);
1271
+ if (cached) {
1272
+ return cached;
1273
+ }
789
1274
  try {
790
1275
  const token = await this.getFeishuAppAccessToken(account);
791
1276
  const result = await feishuJsonRequest(
@@ -794,45 +1279,58 @@ var UserBindRuntime = class {
794
1279
  token
795
1280
  );
796
1281
  const scopes = extractScopes(result);
797
- this.feishuScopeStatus = {
1282
+ const status = {
798
1283
  scopes,
799
1284
  missingIdentityScopes: REQUIRED_FEISHU_IDENTITY_SCOPES.filter((scope) => !scopes.includes(scope)),
800
1285
  hasDocumentAccess: scopes.some((scope) => scope.startsWith("bitable:") || scope.startsWith("drive:") || scope.startsWith("docx:") || scope.startsWith("docs:"))
801
1286
  };
1287
+ this.feishuScopeStatusCache.set(account.accountId, status);
802
1288
  logUserBindEvent("feishu-scopes-read", {
803
1289
  accountId: account.accountId,
804
- ...this.feishuScopeStatus
1290
+ ...status
805
1291
  });
806
- return this.feishuScopeStatus;
1292
+ return status;
807
1293
  } catch (error) {
808
1294
  const message = error instanceof Error ? error.message : String(error);
809
1295
  logUserBindEvent("feishu-scopes-attempt-failed", { accountId: account.accountId, message });
810
1296
  }
811
1297
  }
1298
+ const accounts = readFeishuAccountsFromOpenClawConfig();
1299
+ for (const candidate of accounts) {
1300
+ const cached = this.feishuScopeStatusCache.get(candidate.accountId);
1301
+ if (cached?.hasDocumentAccess) {
1302
+ return cached;
1303
+ }
1304
+ }
1305
+ for (const candidate of accounts) {
1306
+ const status = await this.ensureFeishuScopeStatus(candidate);
1307
+ if (status.hasDocumentAccess) {
1308
+ return status;
1309
+ }
1310
+ }
812
1311
  const executor = this.host.callTool ?? this.host.invokeTool;
813
1312
  if (typeof executor === "function") {
814
1313
  try {
815
1314
  const result = await executor.call(this.host, "feishu_app_scopes", {});
816
1315
  const scopes = extractScopes(result);
817
- this.feishuScopeStatus = {
1316
+ const status = {
818
1317
  scopes,
819
1318
  missingIdentityScopes: REQUIRED_FEISHU_IDENTITY_SCOPES.filter((scope) => !scopes.includes(scope)),
820
1319
  hasDocumentAccess: scopes.some((scope) => scope.startsWith("bitable:") || scope.startsWith("drive:") || scope.startsWith("docx:") || scope.startsWith("docs:"))
821
1320
  };
822
- logUserBindEvent("feishu-scopes-read", this.feishuScopeStatus);
823
- return this.feishuScopeStatus;
1321
+ logUserBindEvent("feishu-scopes-read", status);
1322
+ return status;
824
1323
  } catch (error) {
825
1324
  const message = error instanceof Error ? error.message : String(error);
826
1325
  logUserBindEvent("feishu-scopes-failed", { message });
827
1326
  this.store.recordIssue("feishu-scope-read", message);
828
1327
  }
829
1328
  }
830
- this.feishuScopeStatus = {
1329
+ return {
831
1330
  scopes: [],
832
1331
  missingIdentityScopes: [...REQUIRED_FEISHU_IDENTITY_SCOPES],
833
1332
  hasDocumentAccess: false
834
1333
  };
835
- return this.feishuScopeStatus;
836
1334
  }
837
1335
  async getFeishuAppAccessToken(account) {
838
1336
  const cached = this.feishuTokenCache.get(account.accountId);
@@ -993,6 +1491,9 @@ function exposeGlobalApi(runtime) {
993
1491
  },
994
1492
  async resolveIdentity(context) {
995
1493
  return runtime.resolveFromContext(context);
1494
+ },
1495
+ async runPendingBindingSweep() {
1496
+ return runtime.runPendingBindingSweep();
996
1497
  }
997
1498
  };
998
1499
  }
@@ -1084,9 +1585,8 @@ function ensureToolNames(tools, values) {
1084
1585
  return changed;
1085
1586
  }
1086
1587
  function ensureAgentSkills(agents, skillId) {
1087
- const list = Array.isArray(agents.list) ? agents.list : [];
1088
1588
  let changed = false;
1089
- for (const item of list) {
1589
+ for (const item of iterAgentConfigs(agents)) {
1090
1590
  if (!item || typeof item !== "object") {
1091
1591
  continue;
1092
1592
  }
@@ -1101,10 +1601,9 @@ function ensureAgentSkills(agents, skillId) {
1101
1601
  return changed;
1102
1602
  }
1103
1603
  function ensureAdminSkill(agents, skillId, adminAgents) {
1104
- const list = Array.isArray(agents.list) ? agents.list : [];
1105
1604
  let changed = false;
1106
1605
  let attached = false;
1107
- for (const item of list) {
1606
+ for (const item of iterAgentConfigs(agents)) {
1108
1607
  if (!item || typeof item !== "object") {
1109
1608
  continue;
1110
1609
  }
@@ -1121,6 +1620,7 @@ function ensureAdminSkill(agents, skillId, adminAgents) {
1121
1620
  }
1122
1621
  attached = true;
1123
1622
  }
1623
+ const list = Array.isArray(agents.list) ? agents.list : [];
1124
1624
  if (!attached && list.length > 0 && list[0] && typeof list[0] === "object") {
1125
1625
  const agent = list[0];
1126
1626
  const current = Array.isArray(agent.skills) ? [...agent.skills] : [];
@@ -1132,6 +1632,34 @@ function ensureAdminSkill(agents, skillId, adminAgents) {
1132
1632
  }
1133
1633
  return changed;
1134
1634
  }
1635
+ function* iterAgentConfigs(agents) {
1636
+ const seen = /* @__PURE__ */ new Set();
1637
+ const list = Array.isArray(agents.list) ? agents.list : [];
1638
+ for (const item of list) {
1639
+ if (item && typeof item === "object") {
1640
+ const agent = item;
1641
+ seen.add(agent);
1642
+ yield agent;
1643
+ }
1644
+ }
1645
+ if (list.length > 0) {
1646
+ return;
1647
+ }
1648
+ for (const [key, value] of Object.entries(agents)) {
1649
+ if (key === "list" || key === "defaults" || !value || typeof value !== "object" || Array.isArray(value)) {
1650
+ continue;
1651
+ }
1652
+ const agent = value;
1653
+ if (seen.has(agent)) {
1654
+ continue;
1655
+ }
1656
+ seen.add(agent);
1657
+ if (!getConfiguredAgentId(agent)) {
1658
+ agent.id = key;
1659
+ }
1660
+ yield agent;
1661
+ }
1662
+ }
1135
1663
  function mapProfileRow(row) {
1136
1664
  return {
1137
1665
  userId: String(row.user_id),
@@ -1162,12 +1690,12 @@ function parseIdentityContext(context) {
1162
1690
  const metadataText = asNullableString(record.text) ?? asNullableString(message.text) ?? asNullableString(record.content) ?? asNullableString(metadata.text) ?? asNullableString(input.text) ?? asNullableString(findNestedValue(record, ["message", "content", "text"]));
1163
1691
  const conversationInfo = metadataText ? extractTaggedJsonBlock(metadataText, "Conversation info (untrusted metadata)") : null;
1164
1692
  const senderInfo = metadataText ? extractTaggedJsonBlock(metadataText, "Sender (untrusted metadata)") : null;
1165
- const senderIdFromText = metadataText ? extractRegexValue(metadataText, /"sender_id"\s*:\s*"([^"]+)"/) : null;
1693
+ const senderIdFromText = extractSenderIdFromMetadataText(metadataText);
1166
1694
  const senderNameFromText = metadataText ? extractRegexValue(metadataText, /"sender"\s*:\s*"([^"]+)"/) : null;
1167
1695
  const senderNameFromMessageLine = metadataText ? extractRegexValue(metadataText, /\]\s*([^\n::]{1,40})\s*[::]/) : null;
1168
1696
  const sessionId = asNullableString(record.sessionKey) ?? asNullableString(record.sessionId) ?? asNullableString(session.id) ?? asNullableString(conversation.id) ?? asNullableString(metadata.sessionId) ?? asNullableString(input.sessionId) ?? asNullableString(input.session?.id) ?? asNullableString(record.context?.sessionId) ?? asNullableString(conversationInfo?.session_id) ?? asNullableString(conversationInfo?.message_id);
1169
- const channelType = asNullableString(record.channelType) ?? asNullableString(channel.type) ?? asNullableString(metadata.channelType) ?? asNullableString(conversation?.provider) ?? asNullableString(record.provider) ?? asNullableString(conversationInfo?.provider) ?? inferChannelTypeFromSessionId(sessionId);
1170
- const openId = asNullableString(sender.id) ?? asNullableString(sender.open_id) ?? asNullableString(sender.openId) ?? asNullableString(sender.user_id) ?? asNullableString(senderInfo?.id) ?? asNullableString(conversationInfo?.sender_id) ?? senderIdFromText ?? extractOpenIdFromSessionId(sessionId);
1697
+ const channelType = asNullableString(record.channelType) ?? asNullableString(channel.type) ?? asNullableString(metadata.channelType) ?? asNullableString(conversation?.provider) ?? asNullableString(record.provider) ?? asNullableString(conversationInfo?.provider) ?? inferChannelTypeFromSenderId(extractSenderId(record, sender, senderInfo, conversationInfo, message, senderIdFromText)) ?? inferChannelTypeFromSessionId(sessionId);
1698
+ const openId = extractSenderId(record, sender, senderInfo, conversationInfo, message, senderIdFromText) ?? extractOpenIdFromSessionId(sessionId);
1171
1699
  const senderName = asNullableString(sender.name) ?? asNullableString(sender.display_name) ?? asNullableString(senderInfo?.name) ?? asNullableString(conversationInfo?.sender) ?? senderNameFromText ?? senderNameFromMessageLine;
1172
1700
  return {
1173
1701
  sessionId,
@@ -1176,6 +1704,262 @@ function parseIdentityContext(context) {
1176
1704
  senderName
1177
1705
  };
1178
1706
  }
1707
+ function enrichIdentityContext(context) {
1708
+ const record = context && typeof context === "object" ? { ...context } : {};
1709
+ const sessionManager = record.sessionManager && typeof record.sessionManager === "object" ? record.sessionManager : null;
1710
+ if (!sessionManager) {
1711
+ return record;
1712
+ }
1713
+ const sessionSnapshot = readSessionManagerSnapshot(sessionManager);
1714
+ if (!sessionSnapshot) {
1715
+ return record;
1716
+ }
1717
+ if (!record.sessionId && sessionSnapshot.sessionId) {
1718
+ record.sessionId = sessionSnapshot.sessionId;
1719
+ }
1720
+ if (!record.text && sessionSnapshot.metadataText) {
1721
+ record.text = sessionSnapshot.metadataText;
1722
+ }
1723
+ const metadata = record.metadata && typeof record.metadata === "object" ? { ...record.metadata } : {};
1724
+ if (!metadata.text && sessionSnapshot.metadataText) {
1725
+ metadata.text = sessionSnapshot.metadataText;
1726
+ }
1727
+ if (!metadata.sessionId && sessionSnapshot.sessionId) {
1728
+ metadata.sessionId = sessionSnapshot.sessionId;
1729
+ }
1730
+ if (Object.keys(metadata).length > 0) {
1731
+ record.metadata = metadata;
1732
+ }
1733
+ if (!record.channelType && sessionSnapshot.channelType) {
1734
+ record.channelType = sessionSnapshot.channelType;
1735
+ }
1736
+ if (!record.openId && sessionSnapshot.openId) {
1737
+ record.openId = sessionSnapshot.openId;
1738
+ }
1739
+ return record;
1740
+ }
1741
+ function readSessionManagerSnapshot(sessionManager) {
1742
+ try {
1743
+ const getSessionId = sessionManager.getSessionId;
1744
+ const getBranch = sessionManager.getBranch;
1745
+ const sessionId = typeof getSessionId === "function" ? asNullableString(getSessionId()) : null;
1746
+ const branch = typeof getBranch === "function" ? getBranch() : [];
1747
+ if (!Array.isArray(branch) || branch.length === 0) {
1748
+ return sessionId ? {
1749
+ sessionId,
1750
+ metadataText: null,
1751
+ channelType: inferChannelTypeFromSessionId(sessionId),
1752
+ openId: extractOpenIdFromSessionId(sessionId)
1753
+ } : null;
1754
+ }
1755
+ for (let i = branch.length - 1; i >= 0; i -= 1) {
1756
+ const entry = branch[i];
1757
+ if (!entry || typeof entry !== "object") {
1758
+ continue;
1759
+ }
1760
+ const item = entry;
1761
+ const message = item.message && typeof item.message === "object" ? item.message : null;
1762
+ if (item.type !== "message" || !message || message.role !== "user") {
1763
+ continue;
1764
+ }
1765
+ const metadataText = extractMessageText(message);
1766
+ if (!metadataText || !looksLikeIdentityMetadata(metadataText)) {
1767
+ continue;
1768
+ }
1769
+ const openId = extractSenderIdFromMetadataText(metadataText);
1770
+ const channelType = inferChannelTypeFromSenderId(openId) ?? inferChannelTypeFromSessionId(sessionId);
1771
+ return {
1772
+ sessionId,
1773
+ metadataText,
1774
+ channelType,
1775
+ openId
1776
+ };
1777
+ }
1778
+ return sessionId ? {
1779
+ sessionId,
1780
+ metadataText: null,
1781
+ channelType: inferChannelTypeFromSessionId(sessionId),
1782
+ openId: extractOpenIdFromSessionId(sessionId)
1783
+ } : null;
1784
+ } catch (error) {
1785
+ logUserBindEvent("session-manager-read-failed", {
1786
+ error: error instanceof Error ? error.message : String(error)
1787
+ });
1788
+ return null;
1789
+ }
1790
+ }
1791
+ function extractMessageText(message) {
1792
+ const content = message.content;
1793
+ if (typeof content === "string") {
1794
+ return content;
1795
+ }
1796
+ if (!Array.isArray(content)) {
1797
+ return null;
1798
+ }
1799
+ const text = content.filter((part) => part && typeof part === "object" && part.type === "text" && typeof part.text === "string").map((part) => String(part.text)).join("\n");
1800
+ return text || null;
1801
+ }
1802
+ function looksLikeIdentityMetadata(text) {
1803
+ return text.includes("Conversation info (untrusted metadata)") || text.includes("Sender (untrusted metadata)") || /"sender_id"\s*:\s*"/.test(text);
1804
+ }
1805
+ function extractUserUtterance(context) {
1806
+ const record = context && typeof context === "object" ? context : {};
1807
+ const rawText = extractHookContextText(record) ?? readLatestUserMessageFromSessionManager(record.sessionManager);
1808
+ if (!rawText) {
1809
+ return null;
1810
+ }
1811
+ const stripped = stripIdentityMetadata(rawText);
1812
+ if (!stripped) {
1813
+ return null;
1814
+ }
1815
+ if (looksLikeIdentityMetadata(stripped)) {
1816
+ return null;
1817
+ }
1818
+ return stripped;
1819
+ }
1820
+ function extractHookContextText(record) {
1821
+ const directText = normalizeHookText(
1822
+ record.bodyForAgent ?? record.body ?? record.prompt ?? record.text ?? findNestedValue(record, ["context", "bodyForAgent"]) ?? findNestedValue(record, ["context", "body"]) ?? findNestedValue(record, ["context", "text"]) ?? findNestedValue(record, ["context", "content"])
1823
+ );
1824
+ if (directText) {
1825
+ return directText;
1826
+ }
1827
+ const message = findNestedRecord(record, ["message"], ["event", "message"], ["payload", "message"]);
1828
+ const messageText = extractMessageText(message);
1829
+ if (messageText) {
1830
+ return messageText;
1831
+ }
1832
+ const inputText = extractTextFromInput(record.input);
1833
+ if (inputText) {
1834
+ return inputText;
1835
+ }
1836
+ const messages = Array.isArray(record.messages) ? record.messages : [];
1837
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1838
+ const item = messages[i];
1839
+ if (!item || typeof item !== "object") {
1840
+ continue;
1841
+ }
1842
+ const messageRecord = item;
1843
+ const role = asNullableString(messageRecord.role) ?? "user";
1844
+ if (role !== "user") {
1845
+ continue;
1846
+ }
1847
+ const text = normalizeHookText(messageRecord.text ?? messageRecord.content);
1848
+ if (text) {
1849
+ return text;
1850
+ }
1851
+ }
1852
+ return null;
1853
+ }
1854
+ function extractTextFromInput(input) {
1855
+ if (typeof input === "string") {
1856
+ return normalizeHookText(input);
1857
+ }
1858
+ if (!input || typeof input !== "object") {
1859
+ return null;
1860
+ }
1861
+ const record = input;
1862
+ const directText = normalizeHookText(record.text ?? record.content);
1863
+ if (directText) {
1864
+ return directText;
1865
+ }
1866
+ const message = record.message && typeof record.message === "object" ? record.message : null;
1867
+ const messageText = normalizeHookText(message?.text ?? message?.content);
1868
+ if (messageText) {
1869
+ return messageText;
1870
+ }
1871
+ const messages = Array.isArray(record.messages) ? record.messages : [];
1872
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1873
+ const item = messages[i];
1874
+ if (!item || typeof item !== "object") {
1875
+ continue;
1876
+ }
1877
+ const messageRecord = item;
1878
+ const role = asNullableString(messageRecord.role) ?? "user";
1879
+ if (role !== "user") {
1880
+ continue;
1881
+ }
1882
+ const text = normalizeHookText(messageRecord.text ?? messageRecord.content);
1883
+ if (text) {
1884
+ return text;
1885
+ }
1886
+ }
1887
+ return null;
1888
+ }
1889
+ function normalizeHookText(value) {
1890
+ if (typeof value === "string") {
1891
+ return value.trim() || null;
1892
+ }
1893
+ if (Array.isArray(value)) {
1894
+ const text = value.map((item) => {
1895
+ if (!item || typeof item !== "object") {
1896
+ return "";
1897
+ }
1898
+ return asNullableString(item.text) ?? "";
1899
+ }).filter(Boolean).join("\n").trim();
1900
+ return text || null;
1901
+ }
1902
+ return null;
1903
+ }
1904
+ function readLatestUserMessageFromSessionManager(sessionManager) {
1905
+ if (!sessionManager || typeof sessionManager !== "object") {
1906
+ return null;
1907
+ }
1908
+ try {
1909
+ const getBranch = sessionManager.getBranch;
1910
+ const branch = typeof getBranch === "function" ? getBranch() : [];
1911
+ if (!Array.isArray(branch) || branch.length === 0) {
1912
+ return null;
1913
+ }
1914
+ for (let i = branch.length - 1; i >= 0; i -= 1) {
1915
+ const entry = branch[i];
1916
+ if (!entry || typeof entry !== "object") {
1917
+ continue;
1918
+ }
1919
+ const item = entry;
1920
+ const message = item.message && typeof item.message === "object" ? item.message : null;
1921
+ if (item.type !== "message" || !message || message.role !== "user") {
1922
+ continue;
1923
+ }
1924
+ const text = extractMessageText(message);
1925
+ if (text && !looksLikeIdentityMetadata(text)) {
1926
+ return text.trim();
1927
+ }
1928
+ }
1929
+ } catch (error) {
1930
+ logUserBindEvent("session-manager-user-message-read-failed", {
1931
+ error: error instanceof Error ? error.message : String(error)
1932
+ });
1933
+ }
1934
+ return null;
1935
+ }
1936
+ function mergeSemanticCaptureContext(event, context) {
1937
+ const merged = {};
1938
+ if (event && typeof event === "object") {
1939
+ Object.assign(merged, event);
1940
+ }
1941
+ if (context && typeof context === "object") {
1942
+ const record = context;
1943
+ for (const [key, value] of Object.entries(record)) {
1944
+ if (merged[key] === void 0) {
1945
+ merged[key] = value;
1946
+ }
1947
+ }
1948
+ }
1949
+ if (merged.sessionManager === void 0 && context && typeof context === "object") {
1950
+ merged.sessionManager = context.sessionManager;
1951
+ }
1952
+ return merged;
1953
+ }
1954
+ function stripIdentityMetadata(text) {
1955
+ const hadMetadata = looksLikeIdentityMetadata(text);
1956
+ let cleaned = text.replace(/Conversation info \(untrusted metadata\):\s*```json[\s\S]*?```/gi, "").replace(/Sender \(untrusted metadata\):\s*```json[\s\S]*?```/gi, "").replace(/^\s*\[[^\n]*message_id[^\n]*\]\s*$/gim, "").replace(/^\s*\[[^\n]*sender_id[^\n]*\]\s*$/gim, "");
1957
+ if (hadMetadata) {
1958
+ cleaned = cleaned.replace(/^\s*[^\n::]{1,40}[::]\s*/m, "");
1959
+ }
1960
+ cleaned = cleaned.trim();
1961
+ return cleaned || null;
1962
+ }
1179
1963
  function findNestedRecord(root, ...paths) {
1180
1964
  for (const path of paths) {
1181
1965
  const value = findNestedValue(root, path);
@@ -1224,6 +2008,108 @@ function inferChannelTypeFromSessionId(sessionId) {
1224
2008
  if (sessionId.includes(":whatsapp:")) {
1225
2009
  return "whatsapp";
1226
2010
  }
2011
+ if (sessionId.includes(":discord:")) {
2012
+ return "discord";
2013
+ }
2014
+ if (sessionId.includes(":googlechat:")) {
2015
+ return "googlechat";
2016
+ }
2017
+ if (sessionId.includes(":slack:")) {
2018
+ return "slack";
2019
+ }
2020
+ if (sessionId.includes(":mattermost:")) {
2021
+ return "mattermost";
2022
+ }
2023
+ if (sessionId.includes(":signal:")) {
2024
+ return "signal";
2025
+ }
2026
+ if (sessionId.includes(":imessage:")) {
2027
+ return "imessage";
2028
+ }
2029
+ if (sessionId.includes(":msteams:")) {
2030
+ return "msteams";
2031
+ }
2032
+ return null;
2033
+ }
2034
+ function inferChannelTypeFromSenderId(senderId) {
2035
+ if (!senderId) {
2036
+ return null;
2037
+ }
2038
+ if (/^(?:ou|oc)_[A-Za-z0-9_-]+$/.test(senderId)) {
2039
+ return "feishu";
2040
+ }
2041
+ if (/@(?:s\.whatsapp\.net|g\.us)$/.test(senderId)) {
2042
+ return "whatsapp";
2043
+ }
2044
+ if (/^users\/.+/.test(senderId) || /^spaces\/.+/.test(senderId)) {
2045
+ return "googlechat";
2046
+ }
2047
+ return null;
2048
+ }
2049
+ function extractSenderId(record, sender, senderInfo, conversationInfo, message, senderIdFromText) {
2050
+ return firstNonEmptyString(
2051
+ asNullableString(record.openId),
2052
+ asNullableString(record.senderId),
2053
+ asNullableString(record.userId),
2054
+ asNullableString(record.fromId),
2055
+ asNullableString(record.participantId),
2056
+ asNullableString(record.authorId),
2057
+ asNullableString(sender.id),
2058
+ asNullableString(sender.open_id),
2059
+ asNullableString(sender.openId),
2060
+ asNullableString(sender.user_id),
2061
+ asNullableString(sender.userId),
2062
+ asNullableString(sender.sender_id),
2063
+ asNullableString(sender.senderId),
2064
+ asNullableString(sender.from_id),
2065
+ asNullableString(sender.fromId),
2066
+ asNullableString(sender.author_id),
2067
+ asNullableString(sender.authorId),
2068
+ asNullableString(sender.chat_id),
2069
+ asNullableString(sender.chatId),
2070
+ asNullableString(sender.participant),
2071
+ asNullableString(sender.participant_id),
2072
+ asNullableString(sender.participantId),
2073
+ asNullableString(sender.jid),
2074
+ asNullableString(sender.handle),
2075
+ asNullableString(sender.username),
2076
+ asNullableString(sender.phone),
2077
+ asNullableString(sender.phone_number),
2078
+ asNullableString(findNestedValue(sender, ["from", "id"])),
2079
+ asNullableString(findNestedValue(sender, ["author", "id"])),
2080
+ asNullableString(findNestedValue(message, ["from", "id"])),
2081
+ asNullableString(findNestedValue(message, ["author", "id"])),
2082
+ asNullableString(findNestedValue(message, ["user", "id"])),
2083
+ asNullableString(senderInfo?.id),
2084
+ asNullableString(senderInfo?.user_id),
2085
+ asNullableString(senderInfo?.sender_id),
2086
+ asNullableString(conversationInfo?.sender_id),
2087
+ asNullableString(conversationInfo?.user_id),
2088
+ asNullableString(conversationInfo?.from_id),
2089
+ senderIdFromText
2090
+ );
2091
+ }
2092
+ function extractSenderIdFromMetadataText(metadataText) {
2093
+ if (!metadataText) {
2094
+ return null;
2095
+ }
2096
+ return firstNonEmptyString(
2097
+ extractRegexValue(metadataText, /"sender_id"\s*:\s*"([^"]+)"/),
2098
+ extractRegexValue(metadataText, /"user_id"\s*:\s*"([^"]+)"/),
2099
+ extractRegexValue(metadataText, /"from_id"\s*:\s*"([^"]+)"/),
2100
+ extractRegexValue(metadataText, /"author_id"\s*:\s*"([^"]+)"/),
2101
+ extractRegexValue(metadataText, /"chat_id"\s*:\s*"([^"]+)"/),
2102
+ extractRegexValue(metadataText, /"participant"\s*:\s*"([^"]+)"/),
2103
+ extractRegexValue(metadataText, /"jid"\s*:\s*"([^"]+)"/),
2104
+ extractRegexValue(metadataText, /"id"\s*:\s*"((?:ou|oc)_[^"]+)"/)
2105
+ );
2106
+ }
2107
+ function firstNonEmptyString(...values) {
2108
+ for (const value of values) {
2109
+ if (typeof value === "string" && value.trim()) {
2110
+ return value.trim();
2111
+ }
2112
+ }
1227
2113
  return null;
1228
2114
  }
1229
2115
  function extractRegexValue(text, pattern) {
@@ -1234,8 +2120,24 @@ function extractOpenIdFromSessionId(sessionId) {
1234
2120
  if (!sessionId) {
1235
2121
  return null;
1236
2122
  }
1237
- const match = sessionId.match(/:([A-Za-z0-9_-]+)$/);
1238
- return match?.[1] ?? null;
2123
+ for (const channel of ["feishu", "telegram", "whatsapp", "discord", "googlechat", "slack", "mattermost", "signal", "imessage", "msteams"]) {
2124
+ const marker = `:${channel}:`;
2125
+ const channelIndex = sessionId.indexOf(marker);
2126
+ if (channelIndex < 0) {
2127
+ continue;
2128
+ }
2129
+ const remainder = sessionId.slice(channelIndex + marker.length);
2130
+ const modeSeparator = remainder.indexOf(":");
2131
+ if (modeSeparator < 0) {
2132
+ continue;
2133
+ }
2134
+ const senderId = remainder.slice(modeSeparator + 1).trim();
2135
+ if (senderId) {
2136
+ return senderId;
2137
+ }
2138
+ }
2139
+ const match = sessionId.match(/:([^:]+)$/);
2140
+ return match?.[1]?.trim() || null;
1239
2141
  }
1240
2142
  function getAgentIdFromContext(context) {
1241
2143
  const record = context && typeof context === "object" ? context : {};
@@ -1251,6 +2153,17 @@ function sanitizeProfilePatch(params) {
1251
2153
  notes: asNullableString(params.notes)
1252
2154
  };
1253
2155
  }
2156
+ function extractProfilePatchOperations(params) {
2157
+ const operations = {};
2158
+ const fields = ["nickname", "preferences", "personality", "role", "timezone", "notes"];
2159
+ for (const field of fields) {
2160
+ const raw = asNullableString(params[`${field}Operation`]) ?? asNullableString(params[`${field}_operation`]);
2161
+ if (raw === "replace" || raw === "append" || raw === "remove") {
2162
+ operations[field] = raw;
2163
+ }
2164
+ }
2165
+ return Object.keys(operations).length > 0 ? operations : void 0;
2166
+ }
1254
2167
  function parseAdminInstruction(instruction) {
1255
2168
  const normalized = instruction.trim();
1256
2169
  if (/issue|问题|失败/i.test(normalized)) {
@@ -1332,44 +2245,102 @@ function renderIdentityContext(identity) {
1332
2245
  return lines.join("\n");
1333
2246
  }
1334
2247
  function renderProfileMarkdown(profile) {
1335
- const frontmatter = [
2248
+ const notes = sanitizeProfileNotes(profile.notes) ?? defaultProfileNotes();
2249
+ const frontmatterLines = [
1336
2250
  "---",
1337
- `userId: ${escapeFrontmatter(profile.userId)}`,
1338
- `name: ${escapeFrontmatter(profile.name)}`,
1339
- `nickname: ${escapeFrontmatter(profile.nickname)}`,
1340
- `timezone: ${escapeFrontmatter(profile.timezone ?? "Asia/Shanghai")}`,
1341
- `preferences: ${escapeFrontmatter(profile.preferences ?? "\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206")}`,
1342
- `personality: ${escapeFrontmatter(profile.personality)}`,
1343
- `role: ${escapeFrontmatter(profile.role)}`,
2251
+ `userId: ${escapeFrontmatter(profile.userId)}`
2252
+ ];
2253
+ if (profile.name) {
2254
+ frontmatterLines.push(`name: ${escapeFrontmatter(profile.name)}`);
2255
+ }
2256
+ if (profile.nickname) {
2257
+ frontmatterLines.push(`nickname: ${escapeFrontmatter(profile.nickname)}`);
2258
+ }
2259
+ if (profile.timezone) {
2260
+ frontmatterLines.push(`timezone: ${escapeFrontmatter(profile.timezone)}`);
2261
+ }
2262
+ if (profile.preferences) {
2263
+ frontmatterLines.push(`preferences: ${escapeFrontmatter(profile.preferences)}`);
2264
+ }
2265
+ if (profile.personality) {
2266
+ frontmatterLines.push(`personality: ${escapeFrontmatter(profile.personality)}`);
2267
+ }
2268
+ if (profile.role) {
2269
+ frontmatterLines.push(`role: ${escapeFrontmatter(profile.role)}`);
2270
+ }
2271
+ frontmatterLines.push(
1344
2272
  `visibility: ${escapeFrontmatter(profile.visibility)}`,
1345
2273
  `source: ${escapeFrontmatter(profile.source)}`,
1346
2274
  `updatedAt: ${escapeFrontmatter(profile.updatedAt)}`,
2275
+ `syncHash: ${escapeFrontmatter(computeProfilePayloadHash({
2276
+ name: profile.name,
2277
+ nickname: profile.nickname,
2278
+ timezone: profile.timezone,
2279
+ preferences: profile.preferences,
2280
+ personality: profile.personality,
2281
+ role: profile.role,
2282
+ visibility: profile.visibility
2283
+ }, notes))}`,
1347
2284
  "---"
1348
- ].join("\n");
1349
- const notes = sanitizeProfileNotes(profile.notes) ?? defaultProfileNotes();
2285
+ );
2286
+ const frontmatter = frontmatterLines.join("\n");
2287
+ const confirmedProfileLines = renderConfirmedProfileSection(profile);
1350
2288
  return `${frontmatter}
1351
2289
 
1352
2290
  # \u7528\u6237\u753B\u50CF
1353
2291
 
1354
- \u8FD9\u4E2A\u6587\u4EF6\u662F\u5F53\u524D\u7528\u6237\u7684\u53EF\u7F16\u8F91\u753B\u50CF\u955C\u50CF\u3002\u4F60\u53EF\u4EE5\u76F4\u63A5\u4FEE\u6539\u4E0A\u9762\u7684\u5B57\u6BB5\u548C\u4E0B\u9762\u7684\u8BF4\u660E\u5185\u5BB9\u3002
2292
+ \u8FD9\u4E2A\u6587\u4EF6\u7531\u7CFB\u7EDF\u7EF4\u62A4\u3002Frontmatter \u662F\u7CFB\u7EDF\u673A\u5668\u8BFB\u5199\u7684\u7ED3\u6784\u5316\u6E90\uFF1B\u4E0B\u9762\u7684\u201C\u5DF2\u786E\u8BA4\u753B\u50CF\u201D\u662F\u7ED9\u4EBA\u770B\u7684\u540C\u6B65\u955C\u50CF\u3002\u6CA1\u6709\u51FA\u73B0\u7684\u5B57\u6BB5\uFF0C\u8868\u793A\u5F53\u524D\u8FD8\u6CA1\u6709\u786E\u8BA4\uFF0C\u4E0D\u4EE3\u8868\u7A7A\u503C\u7ED3\u8BBA\u3002
1355
2293
 
1356
- ## \u4F7F\u7528\u5EFA\u8BAE
2294
+ ## \u5DF2\u786E\u8BA4\u753B\u50CF
1357
2295
 
1358
- - \u5E38\u7528\u79F0\u547C\uFF1A\u4F8B\u5982\u201C\u8001\u677F\u201D
1359
- - \u65F6\u533A\uFF1A\u4F8B\u5982 Asia/Shanghai
1360
- - \u98CE\u683C\u504F\u597D\uFF1A\u4F8B\u5982\u201C\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206\u201D
1361
- - \u89D2\u8272\u4FE1\u606F\uFF1A\u4F8B\u5982\u5DE5\u4F5C\u804C\u8D23\u3001\u534F\u4F5C\u8FB9\u754C\u3001\u5E38\u89C1\u4EFB\u52A1
1362
- - \u5176\u4ED6\u5907\u6CE8\uFF1A\u4F8B\u5982\u7981\u5FCC\u3001\u4E60\u60EF\u3001\u8F93\u51FA\u504F\u597D
2296
+ ${confirmedProfileLines}
1363
2297
 
1364
- ## \u5907\u6CE8
2298
+ ## \u8865\u5145\u5907\u6CE8
1365
2299
 
1366
2300
  ${notes}
1367
2301
  `;
1368
2302
  }
2303
+ function renderConfirmedProfileSection(profile) {
2304
+ const rows = [];
2305
+ if (profile.name) {
2306
+ rows.push(`- \u59D3\u540D\uFF1A${profile.name}`);
2307
+ }
2308
+ if (profile.nickname) {
2309
+ rows.push(`- \u79F0\u547C\uFF1A${profile.nickname}`);
2310
+ }
2311
+ if (profile.preferences) {
2312
+ rows.push(`- \u56DE\u7B54\u504F\u597D\uFF1A${profile.preferences}`);
2313
+ }
2314
+ if (profile.personality) {
2315
+ rows.push(`- \u98CE\u683C\u504F\u597D\uFF1A${profile.personality}`);
2316
+ }
2317
+ if (profile.role) {
2318
+ rows.push(`- \u89D2\u8272\u8EAB\u4EFD\uFF1A${profile.role}`);
2319
+ }
2320
+ if (profile.timezone) {
2321
+ rows.push(`- \u65F6\u533A\uFF1A${profile.timezone}`);
2322
+ }
2323
+ return rows.length > 0 ? rows.join("\n") : "- \u6682\u65E0\u5DF2\u786E\u8BA4\u7684\u7ED3\u6784\u5316\u753B\u50CF\u5B57\u6BB5";
2324
+ }
2325
+ function computeProfilePayloadHash(patch, notes) {
2326
+ return hashId(JSON.stringify({
2327
+ name: patch.name ?? null,
2328
+ nickname: patch.nickname ?? null,
2329
+ timezone: patch.timezone ?? null,
2330
+ preferences: patch.preferences ?? null,
2331
+ personality: patch.personality ?? null,
2332
+ role: patch.role ?? null,
2333
+ visibility: patch.visibility ?? "private",
2334
+ notes: sanitizeProfileNotes(notes) ?? null
2335
+ }));
2336
+ }
1369
2337
  function parseProfileMarkdown(markdown) {
1370
2338
  const lines = markdown.split(/\r?\n/);
1371
2339
  const patch = {};
1372
2340
  let notes = null;
2341
+ let updatedAt = null;
2342
+ let source = null;
2343
+ let syncHash = null;
1373
2344
  let index = 0;
1374
2345
  if (lines[index] === "---") {
1375
2346
  index += 1;
@@ -1379,7 +2350,15 @@ function parseProfileMarkdown(markdown) {
1379
2350
  if (separatorIndex > 0) {
1380
2351
  const key = line.slice(0, separatorIndex).trim();
1381
2352
  const value = line.slice(separatorIndex + 1).trim();
1382
- applyFrontmatterField(patch, key, value);
2353
+ if (key === "updatedAt") {
2354
+ updatedAt = value === "null" ? null : value;
2355
+ } else if (key === "source") {
2356
+ source = value === "null" ? null : value;
2357
+ } else if (key === "syncHash") {
2358
+ syncHash = value === "null" ? null : value;
2359
+ } else {
2360
+ applyFrontmatterField(patch, key, value);
2361
+ }
1383
2362
  }
1384
2363
  index += 1;
1385
2364
  }
@@ -1388,13 +2367,16 @@ function parseProfileMarkdown(markdown) {
1388
2367
  }
1389
2368
  }
1390
2369
  const body = lines.slice(index).join("\n");
1391
- const notesMatch = body.match(/##\s*备注\s*\n([\s\S]*)$/);
2370
+ const notesMatch = body.match(/##\s*(?:补充备注|备注)\s*\n([\s\S]*)$/);
1392
2371
  if (notesMatch?.[1]) {
1393
2372
  notes = sanitizeProfileNotes(notesMatch[1]);
1394
2373
  }
1395
2374
  return {
1396
2375
  profilePatch: patch,
1397
- notes
2376
+ notes,
2377
+ updatedAt,
2378
+ source,
2379
+ syncHash
1398
2380
  };
1399
2381
  }
1400
2382
  function applyFrontmatterField(patch, key, value) {
@@ -1415,10 +2397,6 @@ function applyFrontmatterField(patch, key, value) {
1415
2397
  patch.visibility = normalized === "shared" ? "shared" : "private";
1416
2398
  }
1417
2399
  }
1418
- function hasProfileDifference(current, patch) {
1419
- const entries = Object.entries(patch);
1420
- return entries.some(([key, value]) => value != null && current[key] !== value);
1421
- }
1422
2400
  function renderYamlList(rows) {
1423
2401
  if (rows.length === 0) {
1424
2402
  return "[]\n";
@@ -1463,6 +2441,273 @@ function asTextResult(value) {
1463
2441
  function asNullableString(value) {
1464
2442
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
1465
2443
  }
2444
+ async function inferSemanticProfileExtraction(messageText, currentProfile) {
2445
+ const model = readProfileExtractorModelFromOpenClawConfig();
2446
+ if (!model) {
2447
+ return null;
2448
+ }
2449
+ const response = await fetch(`${model.baseUrl}/chat/completions`, {
2450
+ method: "POST",
2451
+ headers: {
2452
+ authorization: `Bearer ${model.apiKey}`,
2453
+ "content-type": "application/json; charset=utf-8"
2454
+ },
2455
+ body: JSON.stringify({
2456
+ model: model.modelId,
2457
+ temperature: 0,
2458
+ max_tokens: 400,
2459
+ response_format: { type: "json_object" },
2460
+ messages: [
2461
+ {
2462
+ role: "system",
2463
+ content: [
2464
+ "You extract stable user-profile information from a single user message.",
2465
+ "Return JSON only.",
2466
+ "Only capture durable, reusable traits or preferences that should affect future conversations.",
2467
+ "Ignore transient task requirements, one-off requests, or speculative guesses.",
2468
+ "Prefer precision, but do not miss clear self-descriptions or clear addressing / style preferences.",
2469
+ "Allowed fields: nickname, preferences, personality, role, timezone, notes.",
2470
+ "For each field, also decide the update operation: replace, append, or remove.",
2471
+ "notes is only for durable boundaries or habits that do not fit the structured fields.",
2472
+ "A preferred form of address counts as nickname even when phrased naturally, for example: '\u4EE5\u540E\u53EB\u6211\u4E30\u54E5', '\u53EB\u6211\u4EB2\u7231\u7684\u8001\u5927', '\u4F60\u5C31\u53EB\u6211\u963F\u4E30'.",
2473
+ "A reusable response preference counts as preferences, for example: '\u5148\u7ED9\u7ED3\u8BBA\u518D\u5C55\u5F00', '\u8BF4\u8BDD\u76F4\u63A5\u4E00\u70B9\u4F46\u522B\u592A\u786C', '\u522B\u592A\u5B98\u65B9'.",
2474
+ "Use append when the user adds another stable preference without revoking the old one.",
2475
+ "Use replace when the user corrects or changes an existing stable preference.",
2476
+ "Use remove when the user clearly asks to drop a specific old preference or trait.",
2477
+ "If a message clearly contains both an addressing preference and a communication preference, capture both.",
2478
+ "Do not require rigid trigger phrases. Judge by meaning.",
2479
+ "Do not copy placeholders, examples, or template language.",
2480
+ 'Return exactly this shape: {"should_update":boolean,"confidence":number,"patch":{"nickname":string?,"preferences":string?,"personality":string?,"role":string?,"timezone":string?,"notes":string?},"operations":{"nickname":"replace|append|remove"?,"preferences":"replace|append|remove"?,"personality":"replace|append|remove"?,"role":"replace|append|remove"?,"timezone":"replace|append|remove"?,"notes":"replace|append|remove"?}}'
2481
+ ].join("\n")
2482
+ },
2483
+ {
2484
+ role: "user",
2485
+ content: JSON.stringify({
2486
+ current_profile: {
2487
+ nickname: currentProfile.nickname,
2488
+ preferences: currentProfile.preferences,
2489
+ personality: currentProfile.personality,
2490
+ role: currentProfile.role,
2491
+ timezone: currentProfile.timezone,
2492
+ notes: currentProfile.notes
2493
+ },
2494
+ latest_user_message: messageText
2495
+ })
2496
+ }
2497
+ ]
2498
+ })
2499
+ });
2500
+ const payload = await response.json();
2501
+ if (!response.ok) {
2502
+ throw new Error(`semantic profile extractor request failed: ${JSON.stringify(payload)}`);
2503
+ }
2504
+ const content = extractOpenAiMessageContent(payload);
2505
+ const parsed = parseSemanticExtractionResult(content);
2506
+ if (!parsed) {
2507
+ throw new Error(`semantic profile extractor returned unreadable content: ${content}`);
2508
+ }
2509
+ return parsed;
2510
+ }
2511
+ function extractOpenAiMessageContent(payload) {
2512
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
2513
+ const first = choices[0];
2514
+ if (!first || typeof first !== "object") {
2515
+ return "";
2516
+ }
2517
+ const message = first.message;
2518
+ if (!message || typeof message !== "object") {
2519
+ return "";
2520
+ }
2521
+ const content = message.content;
2522
+ if (typeof content === "string") {
2523
+ return content.trim();
2524
+ }
2525
+ if (!Array.isArray(content)) {
2526
+ return "";
2527
+ }
2528
+ return content.map((item) => {
2529
+ if (!item || typeof item !== "object") {
2530
+ return "";
2531
+ }
2532
+ const record = item;
2533
+ return asNullableString(record.text) ?? "";
2534
+ }).filter(Boolean).join("\n").trim();
2535
+ }
2536
+ function parseSemanticExtractionResult(content) {
2537
+ const jsonText = extractJsonObject(content);
2538
+ if (!jsonText) {
2539
+ return null;
2540
+ }
2541
+ try {
2542
+ const parsed = JSON.parse(jsonText);
2543
+ const patchInput = parsed.patch && typeof parsed.patch === "object" ? parsed.patch : {};
2544
+ const operationsInput = parsed.operations && typeof parsed.operations === "object" ? parsed.operations : {};
2545
+ return {
2546
+ shouldUpdate: parsed.should_update === true || parsed.shouldUpdate === true,
2547
+ confidence: Number(parsed.confidence ?? 0),
2548
+ patch: {
2549
+ nickname: asNullableString(patchInput.nickname),
2550
+ preferences: asNullableString(patchInput.preferences),
2551
+ personality: asNullableString(patchInput.personality),
2552
+ role: asNullableString(patchInput.role),
2553
+ timezone: asNullableString(patchInput.timezone),
2554
+ notes: asNullableString(patchInput.notes)
2555
+ },
2556
+ operations: parseProfilePatchOperations(operationsInput)
2557
+ };
2558
+ } catch {
2559
+ return null;
2560
+ }
2561
+ }
2562
+ function parseProfilePatchOperations(input) {
2563
+ const operations = {};
2564
+ for (const field of ["nickname", "preferences", "personality", "role", "timezone", "notes"]) {
2565
+ const raw = asNullableString(input[field]);
2566
+ if (raw === "replace" || raw === "append" || raw === "remove") {
2567
+ operations[field] = raw;
2568
+ }
2569
+ }
2570
+ return Object.keys(operations).length > 0 ? operations : void 0;
2571
+ }
2572
+ function extractJsonObject(content) {
2573
+ const trimmed = content.trim();
2574
+ if (!trimmed) {
2575
+ return null;
2576
+ }
2577
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
2578
+ if (fenced?.[1]) {
2579
+ return fenced[1].trim();
2580
+ }
2581
+ const start = trimmed.indexOf("{");
2582
+ const end = trimmed.lastIndexOf("}");
2583
+ if (start >= 0 && end > start) {
2584
+ return trimmed.slice(start, end + 1);
2585
+ }
2586
+ return null;
2587
+ }
2588
+ function cleanupSemanticProfilePatch(patch, currentProfile, operations) {
2589
+ const next = {};
2590
+ const nextOperations = {};
2591
+ const nickname = asNullableString(patch.nickname);
2592
+ const preferences = asNullableString(patch.preferences);
2593
+ const personality = asNullableString(patch.personality);
2594
+ const role = asNullableString(patch.role);
2595
+ const timezone = asNullableString(patch.timezone);
2596
+ const notes = asNullableString(patch.notes);
2597
+ if (nickname && nickname !== currentProfile.nickname) {
2598
+ next.nickname = nickname;
2599
+ if (operations?.nickname) {
2600
+ nextOperations.nickname = operations.nickname;
2601
+ }
2602
+ }
2603
+ if (preferences && preferences !== currentProfile.preferences) {
2604
+ next.preferences = preferences;
2605
+ if (operations?.preferences) {
2606
+ nextOperations.preferences = operations.preferences;
2607
+ }
2608
+ }
2609
+ if (personality && personality !== currentProfile.personality) {
2610
+ next.personality = personality;
2611
+ if (operations?.personality) {
2612
+ nextOperations.personality = operations.personality;
2613
+ }
2614
+ }
2615
+ if (role && role !== currentProfile.role) {
2616
+ next.role = role;
2617
+ if (operations?.role) {
2618
+ nextOperations.role = operations.role;
2619
+ }
2620
+ }
2621
+ if (timezone && timezone !== currentProfile.timezone) {
2622
+ next.timezone = timezone;
2623
+ if (operations?.timezone) {
2624
+ nextOperations.timezone = operations.timezone;
2625
+ }
2626
+ }
2627
+ if (notes) {
2628
+ next.notes = notes;
2629
+ if (operations?.notes) {
2630
+ nextOperations.notes = operations.notes;
2631
+ }
2632
+ }
2633
+ return {
2634
+ patch: next,
2635
+ operations: Object.keys(nextOperations).length > 0 ? nextOperations : void 0
2636
+ };
2637
+ }
2638
+ function applyProfilePatchOperations(currentProfile, patch, operations) {
2639
+ const next = {};
2640
+ const fields = ["nickname", "preferences", "personality", "role", "timezone", "notes"];
2641
+ for (const field of fields) {
2642
+ if (patch[field] === void 0) {
2643
+ continue;
2644
+ }
2645
+ const currentValue = currentProfile[field];
2646
+ const incomingValue = asNullableString(patch[field]);
2647
+ const operation = operations?.[field] ?? defaultProfileFieldOperation(field);
2648
+ const resolved = resolveProfileFieldUpdate(field, currentValue, incomingValue, operation);
2649
+ if (resolved !== void 0) {
2650
+ next[field] = resolved;
2651
+ }
2652
+ }
2653
+ return next;
2654
+ }
2655
+ function defaultProfileFieldOperation(field) {
2656
+ if (field === "notes") {
2657
+ return "append";
2658
+ }
2659
+ return "replace";
2660
+ }
2661
+ function resolveProfileFieldUpdate(field, currentValue, incomingValue, operation) {
2662
+ if (operation === "remove") {
2663
+ if (!currentValue) {
2664
+ return void 0;
2665
+ }
2666
+ if (!incomingValue) {
2667
+ return null;
2668
+ }
2669
+ const removed = removeProfileFeatureValue(currentValue, incomingValue);
2670
+ return removed === currentValue ? void 0 : removed;
2671
+ }
2672
+ if (!incomingValue) {
2673
+ return void 0;
2674
+ }
2675
+ if (operation === "append") {
2676
+ if (field === "notes") {
2677
+ const mergedNotes = joinNotes(currentValue, incomingValue);
2678
+ return mergedNotes === currentValue ? void 0 : mergedNotes;
2679
+ }
2680
+ const appended = appendProfileFeatureValue(currentValue, incomingValue);
2681
+ return appended === currentValue ? void 0 : appended;
2682
+ }
2683
+ return incomingValue === currentValue ? void 0 : incomingValue;
2684
+ }
2685
+ function appendProfileFeatureValue(currentValue, incomingValue) {
2686
+ if (!currentValue) {
2687
+ return incomingValue;
2688
+ }
2689
+ const currentItems = splitProfileFeatureValues(currentValue);
2690
+ const incomingItems = splitProfileFeatureValues(incomingValue);
2691
+ const merged = [...currentItems];
2692
+ for (const item of incomingItems) {
2693
+ if (!merged.includes(item)) {
2694
+ merged.push(item);
2695
+ }
2696
+ }
2697
+ return merged.join("\uFF1B");
2698
+ }
2699
+ function removeProfileFeatureValue(currentValue, incomingValue) {
2700
+ const currentItems = splitProfileFeatureValues(currentValue);
2701
+ const removals = new Set(splitProfileFeatureValues(incomingValue));
2702
+ const nextItems = currentItems.filter((item) => !removals.has(item));
2703
+ if (nextItems.length === currentItems.length) {
2704
+ return currentValue;
2705
+ }
2706
+ return nextItems.length > 0 ? nextItems.join("\uFF1B") : null;
2707
+ }
2708
+ function splitProfileFeatureValues(value) {
2709
+ return value.split(/[\n;;,,、]+/u).map((item) => item.trim()).filter(Boolean);
2710
+ }
1466
2711
  function ensureObject(parent, key) {
1467
2712
  const current = parent[key];
1468
2713
  if (current && typeof current === "object" && !Array.isArray(current)) {
@@ -1561,6 +2806,46 @@ function readFeishuAccountsFromOpenClawConfig() {
1561
2806
  return [];
1562
2807
  }
1563
2808
  }
2809
+ function readProfileExtractorModelFromOpenClawConfig() {
2810
+ const openclawConfigPath = (0, import_node_path.join)((0, import_node_os.homedir)(), ".openclaw", "openclaw.json");
2811
+ if (!(0, import_node_fs.existsSync)(openclawConfigPath)) {
2812
+ return null;
2813
+ }
2814
+ try {
2815
+ const parsed = JSON.parse((0, import_node_fs.readFileSync)(openclawConfigPath, "utf8"));
2816
+ const models = parsed.models && typeof parsed.models === "object" ? parsed.models : {};
2817
+ const providers = models.providers && typeof models.providers === "object" ? models.providers : {};
2818
+ const agents = parsed.agents && typeof parsed.agents === "object" ? parsed.agents : {};
2819
+ const defaults = agents.defaults && typeof agents.defaults === "object" ? agents.defaults : {};
2820
+ const defaultModel = defaults.model && typeof defaults.model === "object" ? defaults.model : {};
2821
+ const configuredModel = asNullableString(defaultModel.primary) ?? asNullableString(models.primary);
2822
+ if (!configuredModel || !configuredModel.includes("/")) {
2823
+ return null;
2824
+ }
2825
+ const [providerId, modelId] = configuredModel.split("/", 2);
2826
+ const provider = providers[providerId] && typeof providers[providerId] === "object" ? providers[providerId] : null;
2827
+ if (!provider) {
2828
+ return null;
2829
+ }
2830
+ const api = asNullableString(provider.api);
2831
+ const baseUrl = asNullableString(provider.baseUrl) ?? asNullableString(provider.baseURL);
2832
+ const apiKey = asNullableString(provider.apiKey);
2833
+ if (api !== "openai-completions" || !baseUrl || !apiKey) {
2834
+ return null;
2835
+ }
2836
+ return {
2837
+ providerId,
2838
+ modelId,
2839
+ baseUrl: baseUrl.replace(/\/+$/, ""),
2840
+ apiKey
2841
+ };
2842
+ } catch (error) {
2843
+ logUserBindEvent("profile-extractor-config-read-failed", {
2844
+ message: error instanceof Error ? error.message : String(error)
2845
+ });
2846
+ return null;
2847
+ }
2848
+ }
1564
2849
  function normalizeFeishuAccount(accountId, input, fallback) {
1565
2850
  const record = input && typeof input === "object" ? input : {};
1566
2851
  const enabled = record.enabled !== false && fallback.enabled !== false;
@@ -1602,12 +2887,107 @@ function getConfiguredAgentId(agent) {
1602
2887
  }
1603
2888
  function defaultProfileNotes() {
1604
2889
  return [
1605
- "- \u5EFA\u8BAE\u79F0\u547C\uFF1A\u8001\u677F",
1606
- "- \u65F6\u533A\uFF1AAsia/Shanghai",
1607
- "- \u504F\u597D\uFF1A\u5E7D\u9ED8\u8BD9\u8C10\u7684\u5BF9\u8BDD\u98CE\u683C\uFF0C\u4F46\u662F\u4E0D\u8FC7\u5206",
1608
- "- \u4F60\u53EF\u4EE5\u5728\u8FD9\u91CC\u7EE7\u7EED\u8865\u5145\u5DE5\u4F5C\u80CC\u666F\u3001\u8868\u8FBE\u4E60\u60EF\u3001\u7981\u5FCC\u548C\u957F\u671F\u504F\u597D\u3002"
2890
+ "- \u8FD9\u91CC\u53EA\u8BB0\u5F55\u5DF2\u7ECF\u786E\u8BA4\u7684\u957F\u671F\u753B\u50CF\u4FE1\u606F\uFF0C\u4E0D\u8BB0\u5F55\u4E34\u65F6\u4EFB\u52A1\u8981\u6C42\u3002",
2891
+ `- \u82E5\u7528\u6237\u5C1A\u672A\u660E\u786E\u63D0\u4F9B\u65F6\u533A\uFF0C\u9ED8\u8BA4\u8DDF\u968F\u5F53\u524D\u670D\u52A1\u5668\u65F6\u533A\uFF08\u5F53\u524D\u68C0\u6D4B\u503C\uFF1A${getServerTimezone()}\uFF09\u3002`,
2892
+ "- \u5907\u6CE8\u91CC\u9002\u5408\u5199\u6682\u65F6\u8FD8\u4E0D\u4FBF\u7ED3\u6784\u5316\u3001\u4F46\u5DF2\u7ECF\u786E\u8BA4\u7684\u957F\u671F\u4E60\u60EF\u3001\u8FB9\u754C\u548C\u534F\u4F5C\u504F\u597D\u3002"
1609
2893
  ].join("\n");
1610
2894
  }
2895
+ function createEphemeralUserId(channelType, openId, sessionId) {
2896
+ return `temp_${hashId(`${channelType}:${openId ?? sessionId}`)}`;
2897
+ }
2898
+ function createProvisionalUserId(channelType, openId) {
2899
+ return `${channelType}:oid_${hashId(`${channelType}:${openId}`)}`;
2900
+ }
2901
+ function createStableLocalUserId(channelType, openId, sessionId) {
2902
+ return `${channelType}:ub_${hashId(`${channelType}:${openId ?? sessionId}`)}`;
2903
+ }
2904
+ function isProvisionalScopedUserId(userId) {
2905
+ return /:oid_[a-f0-9]+$/i.test(userId);
2906
+ }
2907
+ function getPendingBindingRetryDelayMs(reason, attempts) {
2908
+ if (reason === "feishu-contact-scope-missing") {
2909
+ return Math.min(30 * 60 * 1e3, Math.max(6e4, attempts * 5 * 6e4));
2910
+ }
2911
+ return Math.min(10 * 60 * 1e3, Math.max(15e3, attempts * 3e4));
2912
+ }
2913
+ function scopeUserId(channelType, userId) {
2914
+ if (!userId) {
2915
+ return userId;
2916
+ }
2917
+ if (channelType === "manual" || channelType === "local") {
2918
+ return userId;
2919
+ }
2920
+ if (userId.startsWith("temp_") || userId.startsWith("ub_")) {
2921
+ return userId;
2922
+ }
2923
+ if (extractChannelFromScopedUserId(userId)) {
2924
+ return userId;
2925
+ }
2926
+ return `${channelType}:${userId}`;
2927
+ }
2928
+ function getExternalUserId(channelType, userId) {
2929
+ if (!userId) {
2930
+ return userId;
2931
+ }
2932
+ if (channelType === "manual" || channelType === "local") {
2933
+ return userId;
2934
+ }
2935
+ if (userId.startsWith("temp_") || userId.startsWith("ub_")) {
2936
+ return userId;
2937
+ }
2938
+ const currentChannel = extractChannelFromScopedUserId(userId);
2939
+ if (currentChannel && userId.startsWith(`${currentChannel}:`)) {
2940
+ return userId.slice(currentChannel.length + 1);
2941
+ }
2942
+ return userId;
2943
+ }
2944
+ function extractChannelFromScopedUserId(userId) {
2945
+ const match = userId.match(/^([a-z][a-z0-9_-]*):(.+)$/i);
2946
+ if (!match) {
2947
+ return null;
2948
+ }
2949
+ return match[1].toLowerCase();
2950
+ }
2951
+ function buildLightweightProfile(args) {
2952
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2953
+ return {
2954
+ userId: args.userId,
2955
+ name: args.profilePatch.name ?? args.current?.name ?? null,
2956
+ gender: args.profilePatch.gender ?? args.current?.gender ?? null,
2957
+ email: args.profilePatch.email ?? args.current?.email ?? null,
2958
+ avatar: args.profilePatch.avatar ?? args.current?.avatar ?? null,
2959
+ nickname: args.profilePatch.nickname ?? args.current?.nickname ?? null,
2960
+ preferences: args.profilePatch.preferences ?? args.current?.preferences ?? null,
2961
+ personality: args.profilePatch.personality ?? args.current?.personality ?? null,
2962
+ role: args.profilePatch.role ?? args.current?.role ?? null,
2963
+ timezone: args.profilePatch.timezone ?? args.current?.timezone ?? getServerTimezone(),
2964
+ notes: args.profilePatch.notes ?? args.current?.notes ?? defaultProfileNotes(),
2965
+ visibility: args.profilePatch.visibility ?? args.current?.visibility ?? "private",
2966
+ source: args.source,
2967
+ updatedAt: now
2968
+ };
2969
+ }
2970
+ function looksLikeFeishuContactScopeError(message) {
2971
+ return message.includes("99991672") || message.includes("permission_violations") || message.includes("contact:contact.base:readonly") || message.includes("contact:contact:access_as_app") || message.includes("contact:contact:readonly");
2972
+ }
2973
+ function renderFeishuContactScopeGuidance(accountIds) {
2974
+ const scopeText = accountIds.length > 0 ? `\u6D89\u53CA\u8D26\u53F7\uFF1A${accountIds.join("\u3001")}\u3002` : "";
2975
+ return [
2976
+ "\u5F53\u524D\u65E0\u6CD5\u5B8C\u6574\u542F\u7528\u7528\u6237\u753B\u50CF\u3002",
2977
+ "\u539F\u56E0\uFF1A\u5BF9\u5E94\u7684 Feishu App \u7F3A\u5C11\u8054\u7CFB\u4EBA\u6743\u9650\uFF0C\u6682\u65F6\u65E0\u6CD5\u7A33\u5B9A\u89E3\u6790\u771F\u5B9E\u7528\u6237\u8EAB\u4EFD\u3002",
2978
+ `${scopeText}\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u5F00\u901A\u8054\u7CFB\u4EBA\u76F8\u5173\u6743\u9650\uFF0C\u4EE5\u4FDD\u8BC1\u7528\u6237\u753B\u50CF\u53EF\u7528\u3002`
2979
+ ].join(" ");
2980
+ }
2981
+ function getServerTimezone() {
2982
+ try {
2983
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
2984
+ if (typeof timezone === "string" && timezone.trim()) {
2985
+ return timezone.trim();
2986
+ }
2987
+ } catch {
2988
+ }
2989
+ return "UTC";
2990
+ }
1611
2991
  function sanitizeProfileNotes(notes) {
1612
2992
  const value = typeof notes === "string" ? notes.trim() : "";
1613
2993
  if (!value) {