@bamdra/bamdra-user-bind 0.1.3 → 0.1.4

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.
Files changed (2) hide show
  1. package/dist/index.js +320 -16
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -59,6 +59,17 @@ var TABLES = {
59
59
  issues: "bamdra_user_bind_issues",
60
60
  audits: "bamdra_user_bind_audits"
61
61
  };
62
+ var REQUIRED_FEISHU_IDENTITY_SCOPES = [
63
+ "contact:user.employee_id:readonly",
64
+ "contact:user.base:readonly"
65
+ ];
66
+ function logUserBindEvent(event, details = {}) {
67
+ try {
68
+ console.info("[bamdra-user-bind]", event, JSON.stringify(details));
69
+ } catch {
70
+ console.info("[bamdra-user-bind]", event);
71
+ }
72
+ }
62
73
  var UserBindStore = class {
63
74
  constructor(dbPath, exportPath, profileMarkdownRoot) {
64
75
  this.dbPath = dbPath;
@@ -412,6 +423,8 @@ var UserBindRuntime = class {
412
423
  config;
413
424
  sessionCache = /* @__PURE__ */ new Map();
414
425
  bindingCache = /* @__PURE__ */ new Map();
426
+ feishuScopeStatus = null;
427
+ bitableMirror = null;
415
428
  close() {
416
429
  this.store.close();
417
430
  }
@@ -446,13 +459,26 @@ var UserBindRuntime = class {
446
459
  const binding = this.store.findBinding(parsed.channelType, parsed.openId);
447
460
  let userId = binding?.userId ?? null;
448
461
  let source = binding?.source ?? "local";
449
- if (!userId && parsed.channelType === "feishu" && parsed.openId) {
450
- const remote = await this.tryResolveFeishuUser(parsed.openId);
451
- if (remote?.userId) {
452
- userId = remote.userId;
453
- source = remote.source;
454
- } else {
455
- this.store.recordIssue("feishu-resolution", `Failed to resolve real user id for ${parsed.openId}`);
462
+ let remoteProfilePatch = {};
463
+ if (parsed.channelType === "feishu" && parsed.openId) {
464
+ const scopeStatus = await this.ensureFeishuScopeStatus();
465
+ if (scopeStatus.missingIdentityScopes.length > 0) {
466
+ const details = `Missing Feishu scopes: ${scopeStatus.missingIdentityScopes.join(", ")}`;
467
+ logUserBindEvent("feishu-scope-missing", {
468
+ openId: parsed.openId,
469
+ missingScopes: scopeStatus.missingIdentityScopes
470
+ });
471
+ this.store.recordIssue("feishu-scope-missing", details);
472
+ }
473
+ if (!userId) {
474
+ const remote = await this.tryResolveFeishuUser(parsed.openId);
475
+ if (remote?.userId) {
476
+ userId = remote.userId;
477
+ source = remote.source;
478
+ remoteProfilePatch = remote.profilePatch;
479
+ } else {
480
+ this.store.recordIssue("feishu-resolution", `Failed to resolve real user id for ${parsed.openId}`);
481
+ }
456
482
  }
457
483
  }
458
484
  if (!userId) {
@@ -465,7 +491,8 @@ var UserBindRuntime = class {
465
491
  openId: parsed.openId,
466
492
  source,
467
493
  profilePatch: {
468
- name: parsed.senderName
494
+ name: parsed.senderName,
495
+ ...remoteProfilePatch
469
496
  }
470
497
  });
471
498
  const identity = {
@@ -483,6 +510,9 @@ var UserBindRuntime = class {
483
510
  expiresAt: Date.now() + this.config.cacheTtlMs,
484
511
  identity
485
512
  });
513
+ if (parsed.channelType === "feishu") {
514
+ await this.syncFeishuMirror(identity);
515
+ }
486
516
  return identity;
487
517
  }
488
518
  async getMyProfile(context) {
@@ -679,9 +709,11 @@ var UserBindRuntime = class {
679
709
  async tryResolveFeishuUser(openId) {
680
710
  const executor = this.host.callTool ?? this.host.invokeTool;
681
711
  if (typeof executor !== "function") {
712
+ logUserBindEvent("feishu-resolution-skipped", { reason: "tool-executor-unavailable" });
682
713
  return null;
683
714
  }
684
715
  try {
716
+ logUserBindEvent("feishu-resolution-start", { openId });
685
717
  const result = await executor.call(this.host, "feishu_user_get", {
686
718
  user_id_type: "open_id",
687
719
  user_id: openId
@@ -691,12 +723,161 @@ var UserBindRuntime = class {
691
723
  ["user", "user_id"],
692
724
  ["data", "user_id"]
693
725
  ]);
694
- return candidate ? { userId: candidate, source: "feishu-api" } : null;
726
+ if (!candidate) {
727
+ logUserBindEvent("feishu-resolution-empty", { openId });
728
+ return null;
729
+ }
730
+ logUserBindEvent("feishu-resolution-success", { openId, userId: candidate });
731
+ return {
732
+ userId: candidate,
733
+ source: "feishu-api",
734
+ profilePatch: {
735
+ name: extractDeepString(result, [["data", "user", "name"], ["user", "name"]]),
736
+ email: extractDeepString(result, [["data", "user", "email"], ["user", "email"]]),
737
+ avatar: extractDeepString(result, [["data", "user", "avatar", "avatar_origin"], ["user", "avatar", "avatar_origin"]])
738
+ }
739
+ };
695
740
  } catch (error) {
696
- this.store.recordIssue("feishu-resolution", error instanceof Error ? error.message : String(error));
741
+ const message = error instanceof Error ? error.message : String(error);
742
+ logUserBindEvent("feishu-resolution-failed", { openId, message });
743
+ this.store.recordIssue("feishu-resolution", message);
697
744
  return null;
698
745
  }
699
746
  }
747
+ async ensureFeishuScopeStatus() {
748
+ if (this.feishuScopeStatus) {
749
+ return this.feishuScopeStatus;
750
+ }
751
+ const executor = this.host.callTool ?? this.host.invokeTool;
752
+ if (typeof executor !== "function") {
753
+ this.feishuScopeStatus = {
754
+ scopes: [],
755
+ missingIdentityScopes: [...REQUIRED_FEISHU_IDENTITY_SCOPES],
756
+ hasDocumentAccess: false
757
+ };
758
+ return this.feishuScopeStatus;
759
+ }
760
+ try {
761
+ const result = await executor.call(this.host, "feishu_app_scopes", {});
762
+ const scopes = extractScopes(result);
763
+ this.feishuScopeStatus = {
764
+ scopes,
765
+ missingIdentityScopes: REQUIRED_FEISHU_IDENTITY_SCOPES.filter((scope) => !scopes.includes(scope)),
766
+ hasDocumentAccess: scopes.some((scope) => scope.startsWith("bitable:") || scope.startsWith("drive:") || scope.startsWith("docx:") || scope.startsWith("docs:"))
767
+ };
768
+ logUserBindEvent("feishu-scopes-read", this.feishuScopeStatus);
769
+ return this.feishuScopeStatus;
770
+ } catch (error) {
771
+ const message = error instanceof Error ? error.message : String(error);
772
+ logUserBindEvent("feishu-scopes-failed", { message });
773
+ this.store.recordIssue("feishu-scope-read", message);
774
+ this.feishuScopeStatus = {
775
+ scopes: [],
776
+ missingIdentityScopes: [...REQUIRED_FEISHU_IDENTITY_SCOPES],
777
+ hasDocumentAccess: false
778
+ };
779
+ return this.feishuScopeStatus;
780
+ }
781
+ }
782
+ async syncFeishuMirror(identity) {
783
+ const scopeStatus = await this.ensureFeishuScopeStatus();
784
+ if (!scopeStatus.hasDocumentAccess) {
785
+ return;
786
+ }
787
+ const executor = this.host.callTool ?? this.host.invokeTool;
788
+ if (typeof executor !== "function") {
789
+ return;
790
+ }
791
+ try {
792
+ const mirror = await this.ensureFeishuBitableMirror(executor.bind(this.host));
793
+ if (!mirror.appToken || !mirror.tableId) {
794
+ return;
795
+ }
796
+ const existing = await executor.call(this.host, "feishu_bitable_list_records", {
797
+ app_token: mirror.appToken,
798
+ table_id: mirror.tableId
799
+ });
800
+ const recordId = findBitableRecordId(existing, identity.userId);
801
+ const fields = {
802
+ user_id: identity.userId,
803
+ channel_type: identity.channelType,
804
+ open_id: identity.senderOpenId,
805
+ name: identity.profile.name,
806
+ nickname: identity.profile.nickname,
807
+ preferences: identity.profile.preferences,
808
+ personality: identity.profile.personality,
809
+ role: identity.profile.role,
810
+ timezone: identity.profile.timezone,
811
+ email: identity.profile.email,
812
+ avatar: identity.profile.avatar
813
+ };
814
+ if (recordId) {
815
+ await executor.call(this.host, "feishu_bitable_update_record", {
816
+ app_token: mirror.appToken,
817
+ table_id: mirror.tableId,
818
+ record_id: recordId,
819
+ fields
820
+ });
821
+ } else {
822
+ await executor.call(this.host, "feishu_bitable_create_record", {
823
+ app_token: mirror.appToken,
824
+ table_id: mirror.tableId,
825
+ fields
826
+ });
827
+ }
828
+ logUserBindEvent("feishu-bitable-sync-success", { userId: identity.userId });
829
+ } catch (error) {
830
+ const message = error instanceof Error ? error.message : String(error);
831
+ logUserBindEvent("feishu-bitable-sync-failed", { userId: identity.userId, message });
832
+ this.store.recordIssue("feishu-bitable-sync", message, identity.userId);
833
+ }
834
+ }
835
+ async ensureFeishuBitableMirror(executor) {
836
+ if (this.bitableMirror?.appToken && this.bitableMirror?.tableId) {
837
+ return this.bitableMirror;
838
+ }
839
+ try {
840
+ const app = await executor("feishu_bitable_create_app", { name: "Bamdra User Bind" });
841
+ const appToken = extractDeepString(app, [
842
+ ["data", "app", "app_token"],
843
+ ["data", "app_token"],
844
+ ["app", "app_token"],
845
+ ["app_token"]
846
+ ]);
847
+ if (!appToken) {
848
+ return { appToken: null, tableId: null };
849
+ }
850
+ const meta = await executor("feishu_bitable_get_meta", { app_token: appToken });
851
+ const tableId = extractDeepString(meta, [
852
+ ["data", "tables", "0", "table_id"],
853
+ ["data", "items", "0", "table_id"],
854
+ ["tables", "0", "table_id"]
855
+ ]);
856
+ if (!tableId) {
857
+ this.store.recordIssue("feishu-bitable-init", "Unable to determine users table id from Feishu bitable metadata");
858
+ return { appToken, tableId: null };
859
+ }
860
+ for (const fieldName of ["user_id", "channel_type", "open_id", "name", "nickname", "preferences", "personality", "role", "timezone", "email", "avatar"]) {
861
+ try {
862
+ await executor("feishu_bitable_create_field", {
863
+ app_token: appToken,
864
+ table_id: tableId,
865
+ field_name: fieldName,
866
+ type: 1
867
+ });
868
+ } catch {
869
+ }
870
+ }
871
+ this.bitableMirror = { appToken, tableId };
872
+ logUserBindEvent("feishu-bitable-ready", this.bitableMirror);
873
+ return this.bitableMirror;
874
+ } catch (error) {
875
+ const message = error instanceof Error ? error.message : String(error);
876
+ logUserBindEvent("feishu-bitable-init-failed", { message });
877
+ this.store.recordIssue("feishu-bitable-init", message);
878
+ return { appToken: null, tableId: null };
879
+ }
880
+ }
700
881
  };
701
882
  function createUserBindPlugin(api) {
702
883
  return new UserBindRuntime(api, api.pluginConfig ?? api.config ?? api.plugin?.config);
@@ -885,12 +1066,20 @@ function mapProfileRow(row) {
885
1066
  }
886
1067
  function parseIdentityContext(context) {
887
1068
  const record = context && typeof context === "object" ? context : {};
888
- const sender = record.sender && typeof record.sender === "object" ? record.sender : {};
889
- const message = record.message && typeof record.message === "object" ? record.message : {};
890
- const sessionId = asNullableString(record.sessionId) ?? asNullableString(record.sessionKey) ?? asNullableString(record.session?.id) ?? asNullableString(record.context?.sessionId);
891
- const channelType = asNullableString(record.channelType) ?? asNullableString(record.channel?.type) ?? asNullableString(message.channel?.type) ?? asNullableString(record.provider);
892
- const openId = asNullableString(sender.id) ?? asNullableString(sender.open_id) ?? asNullableString(sender.openId) ?? asNullableString(message.sender?.id);
893
- const senderName = asNullableString(sender.name) ?? asNullableString(sender.display_name) ?? asNullableString(message.sender?.name);
1069
+ const sender = findNestedRecord(record, ["sender"], ["message", "sender"], ["event", "sender"], ["payload", "sender"]);
1070
+ const message = findNestedRecord(record, ["message"], ["event", "message"], ["payload", "message"]);
1071
+ const session = findNestedRecord(record, ["session"], ["context", "session"]);
1072
+ const channel = findNestedRecord(record, ["channel"], ["message", "channel"], ["event", "channel"], ["payload", "channel"]);
1073
+ const metadataText = asNullableString(record.text) ?? asNullableString(message.text) ?? asNullableString(record.content) ?? asNullableString(findNestedValue(record, ["message", "content", "text"]));
1074
+ const conversationInfo = metadataText ? extractTaggedJsonBlock(metadataText, "Conversation info (untrusted metadata)") : null;
1075
+ const senderInfo = metadataText ? extractTaggedJsonBlock(metadataText, "Sender (untrusted metadata)") : null;
1076
+ const senderIdFromText = metadataText ? extractRegexValue(metadataText, /"sender_id"\s*:\s*"([^"]+)"/) : null;
1077
+ const senderNameFromText = metadataText ? extractRegexValue(metadataText, /"sender"\s*:\s*"([^"]+)"/) : null;
1078
+ const senderNameFromMessageLine = metadataText ? extractRegexValue(metadataText, /\]\s*([^\n::]{1,40})\s*[::]/) : null;
1079
+ const sessionId = asNullableString(record.sessionId) ?? asNullableString(record.sessionKey) ?? asNullableString(session.id) ?? asNullableString(record.context?.sessionId) ?? asNullableString(conversationInfo?.session_id) ?? asNullableString(conversationInfo?.message_id);
1080
+ const channelType = asNullableString(record.channelType) ?? asNullableString(channel.type) ?? asNullableString(record.provider) ?? asNullableString(conversationInfo?.provider) ?? inferChannelTypeFromSessionId(sessionId);
1081
+ 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);
1082
+ const senderName = asNullableString(sender.name) ?? asNullableString(sender.display_name) ?? asNullableString(senderInfo?.name) ?? asNullableString(conversationInfo?.sender) ?? senderNameFromText ?? senderNameFromMessageLine;
894
1083
  return {
895
1084
  sessionId,
896
1085
  channelType,
@@ -898,6 +1087,67 @@ function parseIdentityContext(context) {
898
1087
  senderName
899
1088
  };
900
1089
  }
1090
+ function findNestedRecord(root, ...paths) {
1091
+ for (const path of paths) {
1092
+ const value = findNestedValue(root, path);
1093
+ if (value && typeof value === "object") {
1094
+ return value;
1095
+ }
1096
+ }
1097
+ return {};
1098
+ }
1099
+ function findNestedValue(root, path) {
1100
+ let current = root;
1101
+ for (const part of path) {
1102
+ if (!current || typeof current !== "object") {
1103
+ return null;
1104
+ }
1105
+ current = current[part];
1106
+ }
1107
+ return current;
1108
+ }
1109
+ function extractTaggedJsonBlock(text, label) {
1110
+ const start = text.indexOf(label);
1111
+ if (start < 0) {
1112
+ return null;
1113
+ }
1114
+ const block = text.slice(start).match(/```json\s*([\s\S]*?)\s*```/i);
1115
+ if (!block) {
1116
+ return null;
1117
+ }
1118
+ try {
1119
+ const parsed = JSON.parse(block[1]);
1120
+ return parsed && typeof parsed === "object" ? parsed : null;
1121
+ } catch {
1122
+ return null;
1123
+ }
1124
+ }
1125
+ function inferChannelTypeFromSessionId(sessionId) {
1126
+ if (!sessionId) {
1127
+ return null;
1128
+ }
1129
+ if (sessionId.includes(":feishu:")) {
1130
+ return "feishu";
1131
+ }
1132
+ if (sessionId.includes(":telegram:")) {
1133
+ return "telegram";
1134
+ }
1135
+ if (sessionId.includes(":whatsapp:")) {
1136
+ return "whatsapp";
1137
+ }
1138
+ return null;
1139
+ }
1140
+ function extractRegexValue(text, pattern) {
1141
+ const match = text.match(pattern);
1142
+ return match?.[1]?.trim() || null;
1143
+ }
1144
+ function extractOpenIdFromSessionId(sessionId) {
1145
+ if (!sessionId) {
1146
+ return null;
1147
+ }
1148
+ const match = sessionId.match(/:([A-Za-z0-9_-]+)$/);
1149
+ return match?.[1] ?? null;
1150
+ }
901
1151
  function getAgentIdFromContext(context) {
902
1152
  const record = context && typeof context === "object" ? context : {};
903
1153
  return asNullableString(record.agentId) ?? asNullableString(record.agent?.id) ?? asNullableString(record.agent?.name);
@@ -1141,6 +1391,60 @@ function ensureArrayIncludes(parent, key, value) {
1141
1391
  parent[key] = current;
1142
1392
  return true;
1143
1393
  }
1394
+ function extractScopes(result) {
1395
+ const candidates = [
1396
+ findNestedValue(result, ["data", "scopes"]),
1397
+ findNestedValue(result, ["scopes"]),
1398
+ findNestedValue(result, ["data", "items"])
1399
+ ];
1400
+ for (const candidate of candidates) {
1401
+ if (!Array.isArray(candidate)) {
1402
+ continue;
1403
+ }
1404
+ const scopes = candidate.map((item) => {
1405
+ if (typeof item === "string") {
1406
+ return item;
1407
+ }
1408
+ if (item && typeof item === "object") {
1409
+ const record = item;
1410
+ const scope = record.scope ?? record.name;
1411
+ return typeof scope === "string" ? scope : "";
1412
+ }
1413
+ return "";
1414
+ }).filter(Boolean);
1415
+ if (scopes.length > 0) {
1416
+ return scopes;
1417
+ }
1418
+ }
1419
+ return [];
1420
+ }
1421
+ function findBitableRecordId(result, userId) {
1422
+ const candidates = [
1423
+ findNestedValue(result, ["data", "items"]),
1424
+ findNestedValue(result, ["items"]),
1425
+ findNestedValue(result, ["data", "records"])
1426
+ ];
1427
+ for (const candidate of candidates) {
1428
+ if (!Array.isArray(candidate)) {
1429
+ continue;
1430
+ }
1431
+ for (const item of candidate) {
1432
+ if (!item || typeof item !== "object") {
1433
+ continue;
1434
+ }
1435
+ const record = item;
1436
+ const fields = record.fields && typeof record.fields === "object" ? record.fields : {};
1437
+ if (String(fields.user_id ?? "") !== userId) {
1438
+ continue;
1439
+ }
1440
+ const recordId = record.record_id ?? record.recordId ?? record.id;
1441
+ if (typeof recordId === "string" && recordId.trim()) {
1442
+ return recordId;
1443
+ }
1444
+ }
1445
+ }
1446
+ return null;
1447
+ }
1144
1448
  function getConfiguredAgentId(agent) {
1145
1449
  return asNullableString(agent.id) ?? asNullableString(agent.name) ?? asNullableString(agent.agentId);
1146
1450
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamdra/bamdra-user-bind",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Identity resolution, user profile binding, and admin-safe profile tools for OpenClaw channels.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://www.bamdra.com",