@geoly-ai/social-hub-cli 0.3.15 → 0.3.17
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 +14 -0
- package/dist/cmd-manifest.json +3 -2
- package/dist/cmd-manifest.test.js +1 -0
- package/dist/cmd-manifest.test.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-notify.js +27 -0
- package/dist/register-notify.js.map +1 -1
- package/package.json +3 -3
- package/skills/README.md +1 -1
- package/skills/manifest.json +2 -2
- package/skills/reddit-matrix.lock.json +1 -1
- package/skills/reddit-voc-volume/SKILL.md +54 -10
- package/skills/reddit-voc-volume/matrix-contract.md +9 -0
- package/skills/reddit-voc-volume/references/arctic-shift.md +38 -3
- package/skills/reddit-voc-volume/references/hub-first.md +9 -5
- package/skills/reddit-voc-volume/scripts/reddit_voc_volume.py +516 -3
- package/skills/reddit-voc-volume/tests/test_archived_comments.py +189 -0
- package/skills/social-hub-notifications/SKILL.md +35 -20
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import sys
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "reddit_voc_volume.py"
|
|
14
|
+
SPEC = importlib.util.spec_from_file_location("reddit_voc_volume_under_test", SCRIPT)
|
|
15
|
+
assert SPEC is not None and SPEC.loader is not None
|
|
16
|
+
voc = importlib.util.module_from_spec(SPEC)
|
|
17
|
+
SPEC.loader.exec_module(voc)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ArchivedCommentTests(unittest.TestCase):
|
|
21
|
+
def run_main(self, *args: str) -> tuple[int, dict]:
|
|
22
|
+
stdout = io.StringIO()
|
|
23
|
+
with mock.patch.object(sys, "argv", [str(SCRIPT), *args]), contextlib.redirect_stdout(stdout):
|
|
24
|
+
code = voc.main()
|
|
25
|
+
return code, json.loads(stdout.getvalue())
|
|
26
|
+
|
|
27
|
+
def test_post_and_comment_id_normalization(self) -> None:
|
|
28
|
+
self.assertEqual(
|
|
29
|
+
voc.normalize_reddit_post_id("https://www.reddit.com/r/example/comments/AbC123/a_slug/"),
|
|
30
|
+
"abc123",
|
|
31
|
+
)
|
|
32
|
+
self.assertEqual(voc.normalize_reddit_post_id("t3_ABC123"), "abc123")
|
|
33
|
+
self.assertEqual(voc.normalize_reddit_post_id("https://redd.it/AbC123"), "abc123")
|
|
34
|
+
self.assertEqual(voc.normalize_reddit_comment_id("t1_DeF456"), "def456")
|
|
35
|
+
with self.assertRaisesRegex(ValueError, "comment ID"):
|
|
36
|
+
voc.normalize_reddit_post_id("t1_def456")
|
|
37
|
+
with self.assertRaisesRegex(ValueError, "unsupported post URL host"):
|
|
38
|
+
voc.normalize_reddit_post_id("https://example.com/comments/abc123")
|
|
39
|
+
with self.assertRaisesRegex(ValueError, "maximum 500"):
|
|
40
|
+
voc.parse_reddit_comment_ids([f"c{index:x}" for index in range(501)])
|
|
41
|
+
|
|
42
|
+
def test_tree_mode_flattens_body_and_preserves_hierarchy(self) -> None:
|
|
43
|
+
payload = {
|
|
44
|
+
"data": [
|
|
45
|
+
{
|
|
46
|
+
"kind": "t1",
|
|
47
|
+
"data": {
|
|
48
|
+
"id": "root1",
|
|
49
|
+
"name": "t1_root1",
|
|
50
|
+
"link_id": "t3_post1",
|
|
51
|
+
"parent_id": "t3_post1",
|
|
52
|
+
"author": "alice",
|
|
53
|
+
"body": "Root comment",
|
|
54
|
+
"created_utc": 100,
|
|
55
|
+
"score": 9,
|
|
56
|
+
"subreddit": "example",
|
|
57
|
+
"replies": {
|
|
58
|
+
"data": {
|
|
59
|
+
"children": [
|
|
60
|
+
{
|
|
61
|
+
"kind": "t1",
|
|
62
|
+
"data": {
|
|
63
|
+
"id": "child1",
|
|
64
|
+
"name": "t1_child1",
|
|
65
|
+
"link_id": "t3_post1",
|
|
66
|
+
"parent_id": "t1_root1",
|
|
67
|
+
"author": "bob",
|
|
68
|
+
"body": "Child comment",
|
|
69
|
+
"created_utc": 101,
|
|
70
|
+
"score": 3,
|
|
71
|
+
"subreddit": "example",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"kind": "more",
|
|
76
|
+
"data": {
|
|
77
|
+
"id": "more1",
|
|
78
|
+
"parent_id": "t1_root1",
|
|
79
|
+
"count": 2,
|
|
80
|
+
"children": ["hidden1", "hidden2"],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
with mock.patch.object(voc, "arctic_get", return_value=payload) as get:
|
|
91
|
+
code, out = self.run_main("comments", "https://reddit.com/r/example/comments/post1/slug")
|
|
92
|
+
|
|
93
|
+
self.assertEqual(code, 0)
|
|
94
|
+
self.assertEqual(get.call_args.args[0], "/api/comments/tree")
|
|
95
|
+
self.assertEqual(get.call_args.args[1]["link_id"], "t3_post1")
|
|
96
|
+
self.assertEqual(get.call_args.args[1]["limit"], 25_000)
|
|
97
|
+
self.assertEqual([item["id"] for item in out["items"]], ["root1", "child1"])
|
|
98
|
+
self.assertEqual(out["items"][0]["child_ids"], ["child1"])
|
|
99
|
+
self.assertEqual(out["items"][1]["parent_id"], "t1_root1")
|
|
100
|
+
self.assertEqual(out["items"][1]["depth"], 1)
|
|
101
|
+
self.assertEqual(out["items"][1]["tree_path"], [0, 0])
|
|
102
|
+
self.assertEqual(out["items"][1]["body"], "Child comment")
|
|
103
|
+
self.assertFalse(out["items"][1]["body_truncated"])
|
|
104
|
+
self.assertFalse(out["tree_complete"])
|
|
105
|
+
self.assertEqual(out["collapsed"][0]["children"], ["hidden1", "hidden2"])
|
|
106
|
+
self.assertFalse(out["archive_caveats"]["score_realtime"])
|
|
107
|
+
|
|
108
|
+
def test_search_mode_forwards_supported_filters(self) -> None:
|
|
109
|
+
with mock.patch.object(voc, "arctic_get", return_value={"data": []}) as get:
|
|
110
|
+
code, out = self.run_main(
|
|
111
|
+
"comments",
|
|
112
|
+
"t3_post1",
|
|
113
|
+
"--source",
|
|
114
|
+
"search",
|
|
115
|
+
"--body",
|
|
116
|
+
"battery life",
|
|
117
|
+
"--author",
|
|
118
|
+
"alice",
|
|
119
|
+
"--after",
|
|
120
|
+
"2024-01-01",
|
|
121
|
+
"--before",
|
|
122
|
+
"2024-02-01",
|
|
123
|
+
"--parent-id",
|
|
124
|
+
"top",
|
|
125
|
+
"--limit",
|
|
126
|
+
"10",
|
|
127
|
+
"--sort",
|
|
128
|
+
"desc",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
self.assertEqual(code, 0)
|
|
132
|
+
self.assertEqual(out["retrieval_mode"], "search")
|
|
133
|
+
self.assertEqual(get.call_args.args[0], "/api/comments/search")
|
|
134
|
+
params = get.call_args.args[1]
|
|
135
|
+
self.assertEqual(params["link_id"], "post1")
|
|
136
|
+
self.assertEqual(params["body"], "battery life")
|
|
137
|
+
self.assertEqual(params["author"], "alice")
|
|
138
|
+
self.assertEqual(params["parent_id"], "")
|
|
139
|
+
self.assertEqual(get.call_args.kwargs["keep_empty_params"], {"parent_id"})
|
|
140
|
+
self.assertEqual(params["limit"], 10)
|
|
141
|
+
self.assertEqual(params["sort"], "desc")
|
|
142
|
+
|
|
143
|
+
def test_ids_mode_restores_requested_order(self) -> None:
|
|
144
|
+
payload = {
|
|
145
|
+
"data": [
|
|
146
|
+
{"id": "second2", "body": "second", "link_id": "t3_post1", "parent_id": "t3_post1"},
|
|
147
|
+
{"id": "first1", "body": "first", "link_id": "t3_post1", "parent_id": "t3_post1"},
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
with mock.patch.object(voc, "arctic_get", return_value=payload) as get:
|
|
151
|
+
code, out = self.run_main("comments", "--ids", "t1_first1,second2")
|
|
152
|
+
|
|
153
|
+
self.assertEqual(code, 0)
|
|
154
|
+
self.assertEqual(get.call_args.args[0], "/api/comments/ids")
|
|
155
|
+
self.assertEqual(get.call_args.args[1]["ids"], "first1,second2")
|
|
156
|
+
self.assertEqual([item["id"] for item in out["items"]], ["first1", "second2"])
|
|
157
|
+
|
|
158
|
+
def test_tree_failure_and_malformed_payload_are_never_reported_complete(self) -> None:
|
|
159
|
+
with mock.patch.object(voc, "arctic_get", side_effect=voc.ArcticError("upstream unavailable")):
|
|
160
|
+
code, out = self.run_main("comments", "post1")
|
|
161
|
+
self.assertEqual(code, 1)
|
|
162
|
+
self.assertFalse(out["tree_complete"])
|
|
163
|
+
self.assertIn("upstream unavailable", out["errors"][0])
|
|
164
|
+
|
|
165
|
+
with mock.patch.object(voc, "arctic_get", return_value={"data": {}}):
|
|
166
|
+
code, out = self.run_main("comments", "post1")
|
|
167
|
+
self.assertEqual(code, 1)
|
|
168
|
+
self.assertFalse(out["tree_complete"])
|
|
169
|
+
self.assertIn("arctic_comments_tree_malformed_payload", out["errors"][0])
|
|
170
|
+
|
|
171
|
+
def test_malformed_id_and_out_of_range_limits_fail_before_network(self) -> None:
|
|
172
|
+
with mock.patch.object(voc, "arctic_get") as get, contextlib.redirect_stderr(io.StringIO()):
|
|
173
|
+
with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "--ids", "t3_wrongkind"]):
|
|
174
|
+
with self.assertRaisesRegex(SystemExit, "2"):
|
|
175
|
+
voc.main()
|
|
176
|
+
with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "post1", "--limit", "25001"]):
|
|
177
|
+
with self.assertRaisesRegex(SystemExit, "2"):
|
|
178
|
+
voc.main()
|
|
179
|
+
with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "post1", "--parent-id", "top"]):
|
|
180
|
+
with self.assertRaisesRegex(SystemExit, "2"):
|
|
181
|
+
voc.main()
|
|
182
|
+
with mock.patch.object(sys, "argv", [str(SCRIPT), "comments", "--ids", "comment1", "--source", "search"]):
|
|
183
|
+
with self.assertRaisesRegex(SystemExit, "2"):
|
|
184
|
+
voc.main()
|
|
185
|
+
get.assert_not_called()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
unittest.main()
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
不用于 OpenClaw 任务领取(那是 social-hub-ops-runtime 的 claim/complete,有租约与终态)、
|
|
10
10
|
也不用于飞书审核通知渠道(social-hub-admin)。
|
|
11
11
|
metadata:
|
|
12
|
-
cliVersion: ">=0.3.
|
|
12
|
+
cliVersion: ">=0.3.17"
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
# social-hub-notifications
|
|
@@ -372,25 +372,26 @@ reset 会 **bump `receiptEpoch`**,作废全部在途 receipt。**绝不要**
|
|
|
372
372
|
|
|
373
373
|
## 常见故障对照
|
|
374
374
|
|
|
375
|
-
| 现象
|
|
376
|
-
|
|
|
377
|
-
| `--follow` 一直不动、心跳报同一个 `pendingSeq`
|
|
378
|
-
| 游标永远卡在某条
|
|
379
|
-
| ack 一直 409
|
|
380
|
-
| pull 403 且 details 有 `missingPermissions`
|
|
381
|
-
| 某字段突然不见了
|
|
382
|
-
| 一整段时间没有事件
|
|
383
|
-
| 某个帖子一条通知都没收到
|
|
384
|
-
| 老 schedule 该被清理却一直在
|
|
385
|
-
| 建订阅被拒,文案带 `template_disabled`
|
|
386
|
-
| 建订阅被拒,文案带 `template_missing`
|
|
387
|
-
| pull 403 `SUBSCRIPTION_SHARED_SENSITIVITY_FORBIDDEN`
|
|
388
|
-
|
|
|
389
|
-
| 跨团队
|
|
390
|
-
|
|
|
391
|
-
| 建订阅 400 `
|
|
392
|
-
| 建订阅
|
|
393
|
-
|
|
|
375
|
+
| 现象 | 原因 | 处理 |
|
|
376
|
+
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
377
|
+
| `--follow` 一直不动、心跳报同一个 `pendingSeq` | 你没 ack(多半是未知 schema 被隔离了) | 看 stderr 的 eventId;升级 handler 后重跑 |
|
|
378
|
+
| 游标永远卡在某条 | 把 tombstone 当业务事件处理失败了 | `payloadStatus: "expired"` **必须** ack |
|
|
379
|
+
| ack 一直 409 | 在重放旧 receipt | 重新 pull 拿新 receipt;409 是 CAS 语义,不做幂等成功 |
|
|
380
|
+
| pull 403 且 details 有 `missingPermissions` | 字段权限不足 | **整个订阅**被拒(不做字段级脱敏):减少 `--require-fields` 或补权限 |
|
|
381
|
+
| 某字段突然不见了 | 后台改了 `selectedFields`(`fieldSetVersion` 会变) | 按 envelope 版本分支;**不要**自己补默认值 |
|
|
382
|
+
| 一整段时间没有事件 | source 被 kill switch 关了 | 先 `notify schedules summary`(**不要**从事件流反推:kill switch 的 missed 不产生事件),再 `notify source-configs list`;disabled 期间到点的节点标 missed,**重新启用不补发** |
|
|
383
|
+
| 某个帖子一条通知都没收到 | 主体被删/未发布/无锚点 → schedule 被 cancel | `notify schedules list --subject <snapshotId> --status cancelled`,看 `cancelReason` |
|
|
384
|
+
| 老 schedule 该被清理却一直在 | 保留期 GC cron 没跑 / 追不上 | `notify schedules purge-status`:`hasRunSinceRegistration: false` = **自本次 worker 注册以来**没观测到运行(**不是**「跑了零删除」;刚重启完看到 false 是正常的);连续多轮 `backlog: true` = 真的追不上,调预算/频率。`protectedGroups` 非零是**正常**的 sentinel 保护,别当积压 |
|
|
385
|
+
| 建订阅被拒,文案带 `template_disabled` | 系统级模板做好了但**自动派生总闸没开** | 运维 `notify templates set-auto-derivation --source <id> --enabled true --apply` |
|
|
386
|
+
| 建订阅被拒,文案带 `template_missing` | 该 source 根本没有系统级模板,该 team 也没人工 config | 运维二选一:建模板(`notify templates create/activate` + 开总闸)或人工建 team config |
|
|
387
|
+
| pull 403 `SUBSCRIPTION_SHARED_SENSITIVITY_FORBIDDEN` | 跨团队/桶订阅碰到目标团队 config 里的 `restricted` 字段 | 看 `details.restrictedFields`;**补权限没用**,请该团队把这几个字段从 `selectedFields` 里去掉 |
|
|
388
|
+
| active config 已无 restricted,但同一 403 仍卡在旧 seq | 历史事件行冻结的 `maxSensitivity` 仍是 restricted,或旧 config 的 scheduled 行还会继续发 restricted 事件 | **不要 ACK/reset/删订阅**。由目标 team 的不受限 admin 先跑 `notify source-configs narrow-history` dry-run,再携 selectionHash `--apply`;操作保留 event seq、dedupe 和订阅游标 |
|
|
389
|
+
| 跨团队 pull 409,文案提 `requiredFields` | 这条共享订阅带着非空 `requiredFields`(多半是早期建的) | PATCH 清空它的 `requiredFields` 后再消费 |
|
|
390
|
+
| 跨团队 `notify events list` / `schedules` 403 | 诊断面**不随**聚合订阅开放(设计如此) | 用 `notify pull` 消费;确需诊断请在该团队补 membership |
|
|
391
|
+
| 建订阅 400 `SUBSCRIPTION_NAME_PREFIX_REQUIRED` | 你不是目标团队成员,名字没落在保留命名空间里 | 用 `details.requiredPrefix` 拼名字重试(`xsub/<你的 userId>/<name>`)。**换普通名字重试没用**——这与 409 `SUBSCRIPTION_NAME_TAKEN`(名字被占,换名即可)是两回事 |
|
|
392
|
+
| 建订阅 400 `SUBSCRIPTION_NAME_PREFIX_RESERVED` | 你**是**本团队成员却用了 `xsub/` 前缀 | 换一个不以 `xsub/` 开头的名字(大小写/全角/前导空格都算命中) |
|
|
393
|
+
| 建订阅 409 `SUBSCRIPTION_QUOTA_EXCEEDED` | 订阅配额满了,看 `details.dimension` / `basis` | `basis=live`:删/复用一条已有订阅。⚠️ `basis=total`:**删订阅不归还额度**(名字永久唯一),只能复用已有订阅或找运维物理清理 / 调高 `NOTIFICATION_*_SUBSCRIPTION_*_LIMIT`。`dimension=target_team` 是**目标团队**总量满了,不是你的问题 |
|
|
394
|
+
| 激活 409 `SOURCE_CONFIG_ACTIVE_STATE_CONFLICT` | 你的 `--expect-active` 与服务端实际 active 不符(别人抢先改了) | 读 `details.actualActiveConfigId` / `actualActiveVersion`,确认对方的改动后再决定是否重试(**无需**再 GET 一次) |
|
|
394
395
|
|
|
395
396
|
## 运维查看面(`notify events` / `notify schedules`)
|
|
396
397
|
|
|
@@ -588,6 +589,20 @@ social-hub notify source-configs activate ... --expect-active none --apply
|
|
|
588
589
|
# 创建即激活也支持同一套(否则「新建并激活」就是绕过 CAS 的后门):
|
|
589
590
|
social-hub notify source-configs create ... --expect-active <当前 activeConfig.id> --apply
|
|
590
591
|
|
|
592
|
+
# 只有「active config 已收窄、历史 restricted 行仍卡住共享订阅」时使用(不受限 admin only)。
|
|
593
|
+
# 第一步是真实服务端 dry-run:会锁内校验旧/新 nodes、filters、retention、事件与未触发 schedule,
|
|
594
|
+
# 但不写数据;保存返回的 selectionHash。
|
|
595
|
+
social-hub notify source-configs narrow-history -t <team> \
|
|
596
|
+
--source post-performance-node.v1 \
|
|
597
|
+
--from-config <旧-config-id> --target-config <当前-active-config-id>
|
|
598
|
+
# 第二步显式执行;选择集有任何漂移都会 409,必须重新 dry-run,绝不沿用旧 hash:
|
|
599
|
+
social-hub notify source-configs narrow-history -t <team> \
|
|
600
|
+
--source post-performance-node.v1 \
|
|
601
|
+
--from-config <旧-config-id> --target-config <当前-active-config-id> \
|
|
602
|
+
--expect-selection-hash <dry-run.selectionHash> --apply
|
|
603
|
+
# 本操作只做 payload 字段单调删除 + scheduled 行 config 迁移;不会 ACK、reset、删除/重建订阅,
|
|
604
|
+
# 也不会推进 cursorSeq / bump receiptEpoch。订阅机器保持原订阅,从原 cursor 自动重试即可。
|
|
605
|
+
|
|
591
606
|
# 实时 kill switch —— 立刻影响【全部在途 schedule】,且不 bump sourceConfigVersion
|
|
592
607
|
social-hub notify source-configs set-enabled -t <team> \
|
|
593
608
|
--source post-performance-node.v1 --enabled false --apply
|