@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.
- package/CHANGELOG.md +17 -0
- package/dist/cmd-manifest.json +6 -2
- package/dist/cmd-manifest.test.js +4 -0
- package/dist/cmd-manifest.test.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/permissions-gates-admin.d.ts.map +1 -1
- package/dist/permissions-gates-admin.js +12 -0
- package/dist/permissions-gates-admin.js.map +1 -1
- package/dist/permissions-gates-notify.d.ts.map +1 -1
- package/dist/permissions-gates-notify.js +6 -0
- package/dist/permissions-gates-notify.js.map +1 -1
- package/dist/register-extensions.d.ts.map +1 -1
- package/dist/register-extensions.js +15 -1
- package/dist/register-extensions.js.map +1 -1
- package/dist/register-feishu-identities.d.ts +47 -0
- package/dist/register-feishu-identities.d.ts.map +1 -0
- package/dist/register-feishu-identities.js +346 -0
- package/dist/register-feishu-identities.js.map +1 -0
- package/dist/register-feishu-identities.test.d.ts +2 -0
- package/dist/register-feishu-identities.test.d.ts.map +1 -0
- package/dist/register-feishu-identities.test.js +124 -0
- package/dist/register-feishu-identities.test.js.map +1 -0
- package/dist/register-notify.js +27 -0
- package/dist/register-notify.js.map +1 -1
- package/package.json +3 -3
- package/skills/manifest.json +1 -1
- package/skills/reddit-matrix.lock.json +1 -1
- package/skills/reddit-voc-volume/SKILL.md +2 -7
- package/skills/reddit-voc-volume/matrix-contract.md +4 -3
- package/skills/reddit-voc-volume/references/arctic-shift.md +2 -1
- package/skills/reddit-voc-volume/scripts/reddit_voc_volume.py +157 -69
- package/skills/reddit-voc-volume/tests/test_archived_comments.py +104 -0
- package/skills/social-hub-admin/SKILL.md +47 -1
- package/skills/social-hub-cli/SKILL.md +1 -0
- package/skills/social-hub-notifications/SKILL.md +35 -20
- 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.
|
|
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]
|
|
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
|
-
|
|
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/"
|
|
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
|
-
|
|
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
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
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
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
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
|
|
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
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
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
|
-
|
|
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
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
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
|
|
3574
|
-
by_id = {
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
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":
|
|
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.
|
|
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` |
|