@geoly-ai/social-hub-cli 0.3.16 → 0.3.18

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 (38) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cmd-manifest.json +6 -2
  3. package/dist/cmd-manifest.test.js +4 -0
  4. package/dist/cmd-manifest.test.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +5 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/permissions-gates-admin.d.ts.map +1 -1
  9. package/dist/permissions-gates-admin.js +12 -0
  10. package/dist/permissions-gates-admin.js.map +1 -1
  11. package/dist/permissions-gates-notify.d.ts.map +1 -1
  12. package/dist/permissions-gates-notify.js +6 -0
  13. package/dist/permissions-gates-notify.js.map +1 -1
  14. package/dist/register-extensions.d.ts.map +1 -1
  15. package/dist/register-extensions.js +15 -1
  16. package/dist/register-extensions.js.map +1 -1
  17. package/dist/register-feishu-identities.d.ts +47 -0
  18. package/dist/register-feishu-identities.d.ts.map +1 -0
  19. package/dist/register-feishu-identities.js +346 -0
  20. package/dist/register-feishu-identities.js.map +1 -0
  21. package/dist/register-feishu-identities.test.d.ts +2 -0
  22. package/dist/register-feishu-identities.test.d.ts.map +1 -0
  23. package/dist/register-feishu-identities.test.js +124 -0
  24. package/dist/register-feishu-identities.test.js.map +1 -0
  25. package/dist/register-notify.js +27 -0
  26. package/dist/register-notify.js.map +1 -1
  27. package/package.json +3 -3
  28. package/skills/manifest.json +1 -1
  29. package/skills/reddit-matrix.lock.json +1 -1
  30. package/skills/reddit-voc-volume/SKILL.md +2 -7
  31. package/skills/reddit-voc-volume/matrix-contract.md +4 -3
  32. package/skills/reddit-voc-volume/references/arctic-shift.md +2 -1
  33. package/skills/reddit-voc-volume/scripts/reddit_voc_volume.py +157 -69
  34. package/skills/reddit-voc-volume/tests/test_archived_comments.py +104 -0
  35. package/skills/social-hub-admin/SKILL.md +47 -1
  36. package/skills/social-hub-cli/SKILL.md +1 -0
  37. package/skills/social-hub-notifications/SKILL.md +35 -20
  38. package/skills/social-hub-posts/SKILL.md +14 -1
@@ -145,7 +145,7 @@ SOCIAL_HUB_REQUIRED_SKILLS = ("social-hub-cli", "social-hub-shared")
145
145
  HUB_DEFAULT_POLL_TIMEOUT_SEC = 600.0
146
146
  HUB_POLL_BACKOFF_SEC = (5.0, 10.0, 20.0, 30.0)
147
147
  HUB_INTELLIGENCE_TIMEOUT_SEC = 30.0
148
- COLLECTOR_VERSION = "2.12.31"
148
+ COLLECTOR_VERSION = "2.12.32"
149
149
  ACQUISITION_POLICIES = ("hub-first", "hub-only", "self-first")
150
150
  DEFAULT_FLAIR_FILTER_PAGES = ("", "hot", "new", "top")
151
151
 
@@ -3285,7 +3285,7 @@ def arctic_search_posts(
3285
3285
  return [arctic_post_item(r) for r in rows if isinstance(r, dict)]
3286
3286
 
3287
3287
 
3288
- _ARCTIC_BASE36_ID_RE = re.compile(r"^[0-9a-z]+$", re.I)
3288
+ _ARCTIC_BASE36_ID_RE = re.compile(r"^[0-9a-z]{1,32}$", re.I)
3289
3289
 
3290
3290
 
3291
3291
  def normalize_reddit_post_id(target: str) -> str:
@@ -3300,14 +3300,17 @@ def normalize_reddit_post_id(target: str) -> str:
3300
3300
  parsed = urllib.parse.urlparse(raw)
3301
3301
  host = (parsed.hostname or "").lower().rstrip(".")
3302
3302
  if host == "redd.it" or host.endswith(".redd.it"):
3303
- candidate = parsed.path.strip("/").split("/", 1)[0]
3303
+ path_parts = parsed.path.strip("/").split("/")
3304
+ candidate = path_parts[0] if len(path_parts) == 1 else ""
3304
3305
  elif host == "reddit.com" or host.endswith(".reddit.com"):
3305
3306
  match = re.search(r"/comments/([0-9a-z]+)(?:/|$)", parsed.path, re.I)
3306
3307
  candidate = match.group(1) if match else ""
3307
3308
  else:
3308
3309
  raise ValueError(f"unsupported post URL host: {host or '<missing>'}")
3309
- elif "/comments/" in raw:
3310
+ elif re.match(r"^/?(?:r/[^/]+/)?comments/", raw, re.I):
3310
3311
  match = re.search(r"/comments/([0-9a-z]+)(?:/|$)", raw, re.I)
3312
+ if match is None:
3313
+ match = re.search(r"^comments/([0-9a-z]+)(?:/|$)", raw, re.I)
3311
3314
  candidate = match.group(1) if match else ""
3312
3315
  else:
3313
3316
  candidate = raw[3:] if raw.lower().startswith("t3_") else raw
@@ -3361,6 +3364,18 @@ def normalize_comment_parent_id(parent_id: str | None, *, allow_top_level: bool
3361
3364
  return f"t1_{normalize_reddit_comment_id(raw)}"
3362
3365
 
3363
3366
 
3367
+ def _arctic_comment_rows(payload: Any, *, endpoint: str) -> list[dict[str, Any]]:
3368
+ """Require the documented ``{"data": object[]}`` comment response shape."""
3369
+ if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
3370
+ raise ArcticError(f"arctic_comments_{endpoint}_malformed_payload: expected data[]")
3371
+ rows = payload["data"]
3372
+ if any(not isinstance(row, dict) for row in rows):
3373
+ raise ArcticError(
3374
+ f"arctic_comments_{endpoint}_malformed_payload: data must contain object rows"
3375
+ )
3376
+ return rows
3377
+
3378
+
3364
3379
  def arctic_comment_item(
3365
3380
  row: dict[str, Any],
3366
3381
  *,
@@ -3372,10 +3387,15 @@ def arctic_comment_item(
3372
3387
  ) -> dict[str, Any]:
3373
3388
  """Map an Arctic comment row without truncating its archived body."""
3374
3389
  body = row.get("body")
3390
+ if body is not None and not isinstance(body, str):
3391
+ raise ArcticError("arctic_comments_malformed_comment: body must be a string or null")
3375
3392
  body_text = body if isinstance(body, str) else ("" if body is None else str(body))
3376
3393
  body_status = classify_body(body_text)
3377
3394
  measurable = body_status not in ("Removed", "Deleted")
3378
- comment_id = str(row.get("id") or "").removeprefix("t1_").lower()
3395
+ try:
3396
+ comment_id = normalize_reddit_comment_id(str(row.get("id") or ""))
3397
+ except ValueError as exc:
3398
+ raise ArcticError(f"arctic_comments_malformed_comment: {exc}") from exc
3379
3399
  permalink = str(row.get("permalink") or "")
3380
3400
  if permalink.startswith("/"):
3381
3401
  permalink = f"https://www.reddit.com{permalink}"
@@ -3434,63 +3454,93 @@ def flatten_arctic_comment_tree(payload: Any) -> tuple[list[dict[str, Any]], lis
3434
3454
  roots = payload.get("data")
3435
3455
  if any(not isinstance(node, dict) for node in roots):
3436
3456
  raise ArcticError("arctic_comments_tree_malformed_payload: data must contain object nodes")
3437
- nodes = roots
3438
3457
  items: list[dict[str, Any]] = []
3439
3458
  collapsed: list[dict[str, Any]] = []
3440
- order = 0
3441
-
3442
- def walk(children: list[dict[str, Any]], *, depth: int, path: list[int], parent_id: str | None) -> None:
3443
- nonlocal order
3444
- for sibling_index, node in enumerate(children):
3445
- kind = str(node.get("kind") or "")
3446
- data = node.get("data")
3447
- if not isinstance(data, dict):
3448
- raise ArcticError("arctic_comments_tree_malformed_payload: node.data must be an object")
3449
- node_path = [*path, sibling_index]
3450
- node_order = order
3451
- order += 1
3452
- if kind == "more":
3453
- child_ids = [
3454
- normalize_reddit_comment_id(str(value))
3455
- for value in (data.get("children") or [])
3456
- if str(value or "").strip()
3457
- ]
3458
- collapsed.append(
3459
- {
3460
- "kind": "more",
3461
- "id": data.get("id"),
3462
- "parent_id": data.get("parent_id") or parent_id,
3463
- "count": data.get("count"),
3464
- "children": child_ids,
3465
- "depth": depth,
3466
- "tree_order": node_order,
3467
- "sibling_index": sibling_index,
3468
- "tree_path": node_path,
3469
- }
3459
+ seen_comment_ids: set[str] = set()
3460
+ # Iterative preorder traversal: a valid 25,000-comment chain must not hit
3461
+ # Python's recursion limit merely because the archived thread is deep.
3462
+ stack: list[tuple[dict[str, Any], int, list[int], str | None, int]] = [
3463
+ (node, 0, [sibling_index], None, sibling_index)
3464
+ for sibling_index, node in reversed(list(enumerate(roots)))
3465
+ ]
3466
+
3467
+ while stack:
3468
+ node, depth, node_path, parent_id, sibling_index = stack.pop()
3469
+ kind = str(node.get("kind") or "")
3470
+ data = node.get("data")
3471
+ if not isinstance(data, dict):
3472
+ raise ArcticError("arctic_comments_tree_malformed_payload: node.data must be an object")
3473
+ node_order = len(items) + len(collapsed)
3474
+ if kind == "more":
3475
+ raw_child_ids = data.get("children")
3476
+ if raw_child_ids is None:
3477
+ raw_child_ids = []
3478
+ if not isinstance(raw_child_ids, list):
3479
+ raise ArcticError(
3480
+ "arctic_comments_tree_malformed_payload: more.children must be an array"
3470
3481
  )
3471
- continue
3472
- if kind != "t1":
3473
- raise ArcticError(f"arctic_comments_tree_malformed_payload: unexpected kind={kind or '<empty>'}")
3474
-
3475
- replies = _arctic_tree_children(data.get("replies"))
3476
- item = arctic_comment_item(
3477
- data,
3478
- depth=depth,
3479
- tree_order=node_order,
3480
- sibling_index=sibling_index,
3481
- tree_path=node_path,
3482
- tree_parent_id=parent_id,
3482
+ try:
3483
+ child_ids = [normalize_reddit_comment_id(str(value)) for value in raw_child_ids]
3484
+ except ValueError as exc:
3485
+ raise ArcticError(
3486
+ f"arctic_comments_tree_malformed_payload: invalid more child ID: {exc}"
3487
+ ) from exc
3488
+ collapsed.append(
3489
+ {
3490
+ "kind": "more",
3491
+ "id": data.get("id"),
3492
+ "parent_id": data.get("parent_id") or parent_id,
3493
+ "count": data.get("count"),
3494
+ "children": child_ids,
3495
+ "depth": depth,
3496
+ "tree_order": node_order,
3497
+ "sibling_index": sibling_index,
3498
+ "tree_path": node_path,
3499
+ }
3500
+ )
3501
+ continue
3502
+ if kind != "t1":
3503
+ raise ArcticError(
3504
+ f"arctic_comments_tree_malformed_payload: unexpected kind={kind or '<empty>'}"
3505
+ )
3506
+
3507
+ replies = _arctic_tree_children(data.get("replies"))
3508
+ item = arctic_comment_item(
3509
+ data,
3510
+ depth=depth,
3511
+ tree_order=node_order,
3512
+ sibling_index=sibling_index,
3513
+ tree_path=node_path,
3514
+ tree_parent_id=parent_id,
3515
+ )
3516
+ if item["id"] in seen_comment_ids:
3517
+ raise ArcticError(
3518
+ f"arctic_comments_tree_malformed_payload: duplicate comment ID {item['id']}"
3483
3519
  )
3520
+ seen_comment_ids.add(item["id"])
3521
+ try:
3484
3522
  item["child_ids"] = [
3485
- str(child.get("data", {}).get("id") or "").removeprefix("t1_").lower()
3523
+ normalize_reddit_comment_id(str(child["data"].get("id") or ""))
3486
3524
  for child in replies
3487
3525
  if child.get("kind") == "t1" and isinstance(child.get("data"), dict)
3488
3526
  ]
3489
- items.append(item)
3490
- current_parent = str(data.get("name") or (f"t1_{item['id']}" if item.get("id") else "")) or None
3491
- walk(replies, depth=depth + 1, path=node_path, parent_id=current_parent)
3527
+ except ValueError as exc:
3528
+ raise ArcticError(
3529
+ f"arctic_comments_tree_malformed_payload: invalid child comment ID: {exc}"
3530
+ ) from exc
3531
+ items.append(item)
3532
+ current_parent = str(data.get("name") or f"t1_{item['id']}")
3533
+ for child_index, child in reversed(list(enumerate(replies))):
3534
+ stack.append(
3535
+ (
3536
+ child,
3537
+ depth + 1,
3538
+ [*node_path, child_index],
3539
+ current_parent,
3540
+ child_index,
3541
+ )
3542
+ )
3492
3543
 
3493
- walk(nodes, depth=0, path=[], parent_id=None)
3494
3544
  return items, collapsed
3495
3545
 
3496
3546
 
@@ -3516,7 +3566,13 @@ def arctic_comments_tree(
3516
3566
  user_agent=user_agent,
3517
3567
  tracker=tracker,
3518
3568
  )
3519
- return flatten_arctic_comment_tree(payload)
3569
+ items, collapsed = flatten_arctic_comment_tree(payload)
3570
+ expected_link_id = f"t3_{post_id}"
3571
+ if any(str(item.get("link_id") or "").lower() != expected_link_id for item in items):
3572
+ raise ArcticError(
3573
+ "arctic_comments_tree_malformed_payload: comment link_id does not match requested post"
3574
+ )
3575
+ return items, collapsed
3520
3576
 
3521
3577
 
3522
3578
  def arctic_search_comments(
@@ -3548,14 +3604,17 @@ def arctic_search_comments(
3548
3604
  tracker=tracker,
3549
3605
  keep_empty_params={"parent_id"} if parent_id == "" else None,
3550
3606
  )
3551
- rows = payload.get("data")
3552
- if not isinstance(rows, list):
3553
- return []
3554
- return [
3555
- arctic_comment_item(row, tree_order=index)
3556
- for index, row in enumerate(rows)
3557
- if isinstance(row, dict)
3558
- ]
3607
+ rows = _arctic_comment_rows(payload, endpoint="search")
3608
+ items = [arctic_comment_item(row, tree_order=index) for index, row in enumerate(rows)]
3609
+ expected_link_id = f"t3_{post_id}"
3610
+ if any(str(item.get("link_id") or "").lower() != expected_link_id for item in items):
3611
+ raise ArcticError(
3612
+ "arctic_comments_search_malformed_payload: comment link_id does not match requested post"
3613
+ )
3614
+ item_ids = [str(item["id"]) for item in items]
3615
+ if len(item_ids) != len(set(item_ids)):
3616
+ raise ArcticError("arctic_comments_search_malformed_payload: duplicate comment IDs")
3617
+ return items
3559
3618
 
3560
3619
 
3561
3620
  def arctic_comments_by_ids(
@@ -3570,12 +3629,24 @@ def arctic_comments_by_ids(
3570
3629
  user_agent=user_agent,
3571
3630
  tracker=tracker,
3572
3631
  )
3573
- rows = payload.get("data")
3574
- by_id = {
3575
- str(row.get("id") or "").removeprefix("t1_").lower(): row
3576
- for row in (rows if isinstance(rows, list) else [])
3577
- if isinstance(row, dict)
3578
- }
3632
+ rows = _arctic_comment_rows(payload, endpoint="ids")
3633
+ by_id: dict[str, dict[str, Any]] = {}
3634
+ for row in rows:
3635
+ try:
3636
+ item_id = normalize_reddit_comment_id(str(row.get("id") or ""))
3637
+ except ValueError as exc:
3638
+ raise ArcticError(f"arctic_comments_ids_malformed_payload: {exc}") from exc
3639
+ if item_id in by_id:
3640
+ raise ArcticError(
3641
+ f"arctic_comments_ids_malformed_payload: duplicate comment ID {item_id}"
3642
+ )
3643
+ by_id[item_id] = row
3644
+ unexpected_ids = sorted(set(by_id).difference(comment_ids))
3645
+ if unexpected_ids:
3646
+ raise ArcticError(
3647
+ "arctic_comments_ids_malformed_payload: unexpected comment IDs "
3648
+ + ",".join(unexpected_ids[:10])
3649
+ )
3579
3650
  items = [
3580
3651
  arctic_comment_item(by_id[comment_id], tree_order=index)
3581
3652
  for index, comment_id in enumerate(comment_ids)
@@ -4991,6 +5062,7 @@ def main() -> int:
4991
5062
  missing_ids: list[str] = []
4992
5063
  items: list[dict[str, Any]] = []
4993
5064
  tree_fetch_succeeded = False
5065
+ tree_limit_reached = False
4994
5066
  try:
4995
5067
  if retrieval_mode == "ids":
4996
5068
  items, missing_ids = arctic_comments_by_ids(
@@ -5022,6 +5094,9 @@ def main() -> int:
5022
5094
  tracker=tracker,
5023
5095
  )
5024
5096
  tree_fetch_succeeded = True
5097
+ # At the exact cap, absence of `kind=more` is not proof that the
5098
+ # upstream did not truncate. Completeness must remain fail-closed.
5099
+ tree_limit_reached = len(items) >= limit
5025
5100
  except ArcticError as exc:
5026
5101
  errors.append(f"arctic_comments_failed:{exc}"[:300])
5027
5102
  if missing_ids:
@@ -5038,6 +5113,11 @@ def main() -> int:
5038
5113
  f"评论树含 {len(collapsed)} 个 kind=more 折叠节点;"
5039
5114
  "tree_complete=false,collapsed[].children 保留尚未展开的评论 ID。"
5040
5115
  )
5116
+ if tree_limit_reached:
5117
+ notes.append(
5118
+ f"评论数已触达本次 tree limit={limit};即使没有 kind=more,也不能证明树完整,"
5119
+ "tree_complete=false。"
5120
+ )
5041
5121
  fetched_at = utc_now_iso()
5042
5122
  canonical_source = f"https://www.reddit.com/comments/{post_id}/" if post_id else None
5043
5123
  fingerprint = hashlib.sha256(
@@ -5081,7 +5161,12 @@ def main() -> int:
5081
5161
  "sort": args.sort or ("asc" if retrieval_mode == "search" else None),
5082
5162
  },
5083
5163
  "count": len(items),
5084
- "tree_complete": tree_fetch_succeeded and not collapsed if retrieval_mode == "tree" else None,
5164
+ "tree_complete": (
5165
+ tree_fetch_succeeded and not collapsed and not tree_limit_reached
5166
+ if retrieval_mode == "tree"
5167
+ else None
5168
+ ),
5169
+ "tree_limit_reached": tree_limit_reached if retrieval_mode == "tree" else None,
5085
5170
  "collapsed_count": len(collapsed),
5086
5171
  "collapsed": collapsed,
5087
5172
  "body_stats": {
@@ -5099,6 +5184,9 @@ def main() -> int:
5099
5184
  "requested_limit": len(comment_ids) if retrieval_mode == "ids" else limit,
5100
5185
  "effective_limit": len(comment_ids) if retrieval_mode == "ids" else limit,
5101
5186
  "http_requests": tracker.count,
5187
+ "truncated": bool(collapsed or tree_limit_reached)
5188
+ if retrieval_mode == "tree"
5189
+ else False,
5102
5190
  "rate_limited": tracker.rate_limited,
5103
5191
  },
5104
5192
  "fetched_at": fetched_at,
@@ -31,11 +31,16 @@ class ArchivedCommentTests(unittest.TestCase):
31
31
  )
32
32
  self.assertEqual(voc.normalize_reddit_post_id("t3_ABC123"), "abc123")
33
33
  self.assertEqual(voc.normalize_reddit_post_id("https://redd.it/AbC123"), "abc123")
34
+ self.assertEqual(voc.normalize_reddit_post_id("/r/example/comments/AbC123/slug"), "abc123")
34
35
  self.assertEqual(voc.normalize_reddit_comment_id("t1_DeF456"), "def456")
35
36
  with self.assertRaisesRegex(ValueError, "comment ID"):
36
37
  voc.normalize_reddit_post_id("t1_def456")
37
38
  with self.assertRaisesRegex(ValueError, "unsupported post URL host"):
38
39
  voc.normalize_reddit_post_id("https://example.com/comments/abc123")
40
+ with self.assertRaisesRegex(ValueError, "malformed Reddit post ID"):
41
+ voc.normalize_reddit_post_id("example.com/comments/abc123")
42
+ with self.assertRaisesRegex(ValueError, "malformed Reddit post ID"):
43
+ voc.normalize_reddit_post_id("https://redd.it/abc123/extra")
39
44
  with self.assertRaisesRegex(ValueError, "maximum 500"):
40
45
  voc.parse_reddit_comment_ids([f"c{index:x}" for index in range(501)])
41
46
 
@@ -101,10 +106,70 @@ class ArchivedCommentTests(unittest.TestCase):
101
106
  self.assertEqual(out["items"][1]["tree_path"], [0, 0])
102
107
  self.assertEqual(out["items"][1]["body"], "Child comment")
103
108
  self.assertFalse(out["items"][1]["body_truncated"])
109
+ self.assertTrue(
110
+ {
111
+ "body",
112
+ "id",
113
+ "link_id",
114
+ "parent_id",
115
+ "author",
116
+ "created_utc",
117
+ "score",
118
+ "subreddit",
119
+ }.issubset(out["items"][1])
120
+ )
104
121
  self.assertFalse(out["tree_complete"])
105
122
  self.assertEqual(out["collapsed"][0]["children"], ["hidden1", "hidden2"])
106
123
  self.assertFalse(out["archive_caveats"]["score_realtime"])
107
124
 
125
+ def test_exact_tree_limit_is_not_reported_complete_without_more_node(self) -> None:
126
+ payload = {
127
+ "data": [
128
+ {
129
+ "kind": "t1",
130
+ "data": {
131
+ "id": "only1",
132
+ "link_id": "t3_post1",
133
+ "parent_id": "t3_post1",
134
+ "body": "At the requested cap",
135
+ "replies": "",
136
+ },
137
+ }
138
+ ]
139
+ }
140
+ with mock.patch.object(voc, "arctic_get", return_value=payload):
141
+ code, out = self.run_main("comments", "post1", "--limit", "1")
142
+
143
+ self.assertEqual(code, 0)
144
+ self.assertFalse(out["tree_complete"])
145
+ self.assertTrue(out["tree_limit_reached"])
146
+ self.assertTrue(out["rate_limit"]["truncated"])
147
+ self.assertEqual(out["collapsed"], [])
148
+
149
+ def test_tree_flattener_handles_depth_beyond_python_recursion_limit(self) -> None:
150
+ node = {
151
+ "kind": "t1",
152
+ "data": {"id": "c4ff", "body": "leaf", "replies": ""},
153
+ }
154
+ for index in reversed(range(1_100)):
155
+ node = {
156
+ "kind": "t1",
157
+ "data": {
158
+ "id": f"c{index:x}",
159
+ "body": "nested",
160
+ "replies": {"data": {"children": [node]}},
161
+ },
162
+ }
163
+ node["data"]["replies"]["data"]["children"][0]["data"]["parent_id"] = (
164
+ f"t1_c{index:x}"
165
+ )
166
+
167
+ items, collapsed = voc.flatten_arctic_comment_tree({"data": [node]})
168
+ self.assertEqual(len(items), 1_101)
169
+ self.assertEqual(items[-1]["depth"], 1_100)
170
+ self.assertEqual(items[-1]["tree_order"], 1_100)
171
+ self.assertEqual(collapsed, [])
172
+
108
173
  def test_search_mode_forwards_supported_filters(self) -> None:
109
174
  with mock.patch.object(voc, "arctic_get", return_value={"data": []}) as get:
110
175
  code, out = self.run_main(
@@ -168,6 +233,42 @@ class ArchivedCommentTests(unittest.TestCase):
168
233
  self.assertFalse(out["tree_complete"])
169
234
  self.assertIn("arctic_comments_tree_malformed_payload", out["errors"][0])
170
235
 
236
+ malformed_more = {
237
+ "data": [
238
+ {
239
+ "kind": "more",
240
+ "data": {"children": "not-an-array", "parent_id": "t3_post1"},
241
+ }
242
+ ]
243
+ }
244
+ with mock.patch.object(voc, "arctic_get", return_value=malformed_more):
245
+ code, out = self.run_main("comments", "post1")
246
+ self.assertEqual(code, 1)
247
+ self.assertFalse(out["tree_complete"])
248
+ self.assertIn("more.children must be an array", out["errors"][0])
249
+
250
+ def test_search_and_ids_reject_malformed_upstream_rows(self) -> None:
251
+ for argv, payload, expected in (
252
+ (("comments", "post1", "--source", "search"), {"data": {}}, "expected data[]"),
253
+ (("comments", "--ids", "one1"), {"data": ["bad-row"]}, "object rows"),
254
+ (("comments", "--ids", "one1"), {"data": [{"id": ""}]}, "malformed Reddit comment ID"),
255
+ (
256
+ ("comments", "post1", "--source", "search"),
257
+ {"data": [{"id": "one1", "link_id": "t3_other", "body": "wrong post"}]},
258
+ "link_id does not match",
259
+ ),
260
+ (
261
+ ("comments", "--ids", "one1"),
262
+ {"data": [{"id": "one1", "body": {"unexpected": "object"}}]},
263
+ "body must be a string or null",
264
+ ),
265
+ ):
266
+ with self.subTest(argv=argv, payload=payload):
267
+ with mock.patch.object(voc, "arctic_get", return_value=payload):
268
+ code, out = self.run_main(*argv)
269
+ self.assertEqual(code, 1)
270
+ self.assertIn(expected, out["errors"][0])
271
+
171
272
  def test_malformed_id_and_out_of_range_limits_fail_before_network(self) -> None:
172
273
  with mock.patch.object(voc, "arctic_get") as get, contextlib.redirect_stderr(io.StringIO()):
173
274
  with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "--ids", "t3_wrongkind"]):
@@ -179,6 +280,9 @@ class ArchivedCommentTests(unittest.TestCase):
179
280
  with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "post1", "--parent-id", "top"]):
180
281
  with self.assertRaisesRegex(SystemExit, "2"):
181
282
  voc.main()
283
+ with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "x" * 33]):
284
+ with self.assertRaisesRegex(SystemExit, "2"):
285
+ voc.main()
182
286
  with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "--ids", "comment1", "--source", "search"]):
183
287
  with self.assertRaisesRegex(SystemExit, "2"):
184
288
  voc.main()
@@ -8,8 +8,10 @@ description: >-
8
8
  或管理品牌注册表、persona×brand 适配关系、trigger/avoid/rule_signal 注册表、品牌提及授权时用本 skill;
9
9
  部分既有高风险 admin 命令(settings/api-keys/members 等)需 --apply(品牌治理新命令无 --apply 选项)。
10
10
  API Key 创建/轮换是管理员资源,不是 CLI 日常登录方式。
11
+ 另含 feishu-identities(Hub 用户 ↔ 飞书 open_id 映射的运维同步面,决定告警 @ 谁)——
12
+ 用户说「同步飞书身份」「谁还没映射上飞书」「告警 @ 不到人」时用本 skill。
11
13
  metadata:
12
- cliVersion: ">=0.3.9"
14
+ cliVersion: ">=0.3.18"
13
15
  ---
14
16
 
15
17
  # social-hub-admin
@@ -174,6 +176,50 @@ social-hub notification-channels test -t <team-id> --channel <uuid>
174
176
 
175
177
  `provider: "feishu"` 是 CLI 支持的 provider 枚举,不是飞书数据同步流程。
176
178
 
179
+ ## 飞书身份映射(feishu-identities,运维同步面)
180
+
181
+ 决定**告警卡片会 @ 到谁**:Hub 用户 ↔ 飞书 `open_id`。之前唯一的写入方式是
182
+ `scripts/sync-feishu-identities.mjs`,那个脚本要求运维手里有生产 `DATABASE_URL`;
183
+ 这组命令把同一套语义搬到鉴权后面,只需要一次 `social-hub auth login`。
184
+
185
+ ```bash
186
+ social-hub feishu-identities list
187
+ social-hub feishu-identities list --status unmapped # 谁还没映射上
188
+ social-hub feishu-identities list --status lookup_failed # 谁查失败了(不是"不在通讯录")
189
+
190
+ # 本机 lark-cli 查 open_id → PUT 给 Hub 落库
191
+ social-hub feishu-identities sync --app-id cli_xxx --dry-run
192
+ social-hub feishu-identities sync --app-id cli_xxx --apply
193
+ ```
194
+
195
+ **为什么查询在本机而不是服务端**:飞书 `contact` 查询目前只能用个人授权(bot 缺
196
+ `contact:user.id:readonly`),服务端既没有应用凭据也拿不到这个身份。让 Hub 代查就意味着
197
+ 托管某个人的 refresh token,那个人离职/改密的当天全链路静默失效。所以形态是
198
+ 「本机 `lark-cli` 查 → PUT 落库」,`sync` 依赖本机装了 `lark-cli` 并已授权。
199
+
200
+ ### 三个状态不是同义词(读这段再动手)
201
+
202
+ | 状态 | 含义 | 对既有 open_id 的影响 |
203
+ | --------------- | -------------------------------- | ------------------------ |
204
+ | `resolved` | 查到了 | 覆盖 |
205
+ | `not_found` | 飞书通讯录里**确实没这人** | **清空**(离职语义) |
206
+ | `lookup_failed` | 查询本身失败,我们不知道 | **保留**上次成功解析的值 |
207
+ | (无行) | 从未同步过(`list` 里为 `null`) | — |
208
+
209
+ 把「没问出来」写成 `not_found` 的代价是**静默注销一个本来能收到告警的人**。
210
+
211
+ ### 两道护栏
212
+
213
+ - **写入面只认交互式登录**:API key、服务 token、job/account 绑定的自动化 CLI token
214
+ 一律 403。这批数据是 mention 注入的源头,不该有非人值守的写入方。
215
+ - **防一次误操作清空全部映射**:当此前 `resolved` 的人里超过一半这次变成 `not_found`
216
+ 时 `sync` 直接拒绝退出。登错飞书租户/授权失效正是这个形态,与「公司集体离职」
217
+ 不可区分,必须人来拍板 —— 确认无误才加 `--force-mass-not-found`。
218
+
219
+ `list` 读到的 `openId` 若形如 `ou_***` 说明**已脱敏**(判据是响应里的 `redacted`,
220
+ 不是字符串形态),原始 id 只给不受限的交互式 admin 登录;脱敏值回写会写出一个
221
+ 永远 @ 不到人的映射。
222
+
177
223
  ## 系统级人设库(personas)
178
224
 
179
225
  权限资源:`systemPersona`(权限矩阵中单独一行,与 `systemBrand` 类似)。
@@ -47,6 +47,7 @@ social-hub version --json
47
47
  | 图谱 / 合规 / 风控 | `social-hub-graph-compliance` |
48
48
  | settings / invites / members / api-keys | `social-hub-admin` |
49
49
  | brand-registry / brand-fit / trigger 注册表四眼 / brand-auth | `social-hub-admin` |
50
+ | 飞书身份映射同步(告警 @ 谁) | `social-hub-admin` |
50
51
  | 板块情报 / KOL / insights | `social-hub-intelligence` |
51
52
  | 运营板块池 catalog / aliases / tier rules | `social-hub-subreddit-pools` |
52
53
  | 通用网页抓取 / Firecrawl / scrape | `social-hub-scrape` |