consumer-sdk 1.0.0__py3-none-any.whl
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.
consumer_sdk/__init__.py
ADDED
consumer_sdk/consumer.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from typing import Awaitable, Callable, Optional
|
|
6
|
+
|
|
7
|
+
import redis
|
|
8
|
+
import redis.asyncio as aioredis
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("consumer")
|
|
11
|
+
|
|
12
|
+
MetaHandler = Callable[[dict], Awaitable[None]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DiscordEventConsumer:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
redis_url: str,
|
|
19
|
+
consumer_name: str,
|
|
20
|
+
stream_key: str = "discord:events",
|
|
21
|
+
group_name: Optional[str] = None,
|
|
22
|
+
block_ms: int = 5000,
|
|
23
|
+
batch: int = 50,
|
|
24
|
+
state_file: Optional[str] = None,
|
|
25
|
+
):
|
|
26
|
+
self.redis_url = redis_url
|
|
27
|
+
self.consumer_name = consumer_name
|
|
28
|
+
self.stream_key = stream_key
|
|
29
|
+
self.group_name = group_name or f"group_{consumer_name}"
|
|
30
|
+
self.block_ms = block_ms
|
|
31
|
+
self.batch = batch
|
|
32
|
+
self.state_file = state_file or f".consumer_state_{consumer_name}.json"
|
|
33
|
+
|
|
34
|
+
self.rds: Optional[aioredis.Redis] = None
|
|
35
|
+
self._stop = False
|
|
36
|
+
self._seen: set = set()
|
|
37
|
+
self._load_state()
|
|
38
|
+
|
|
39
|
+
def _load_state(self):
|
|
40
|
+
if os.path.exists(self.state_file):
|
|
41
|
+
try:
|
|
42
|
+
with open(self.state_file, "r", encoding="utf-8") as f:
|
|
43
|
+
data = json.load(f)
|
|
44
|
+
self._seen = set(data.get("seen_event_ids", []))
|
|
45
|
+
if len(self._seen) > 20000:
|
|
46
|
+
self._seen = set(list(self._seen)[-20000:])
|
|
47
|
+
logger.info(f"加载去重状态: {len(self._seen)} 条")
|
|
48
|
+
except Exception as e:
|
|
49
|
+
logger.warning(f"加载状态失败: {e}")
|
|
50
|
+
|
|
51
|
+
def _save_state(self):
|
|
52
|
+
try:
|
|
53
|
+
with open(self.state_file + ".tmp", "w", encoding="utf-8") as f:
|
|
54
|
+
json.dump({"seen_event_ids": list(self._seen)[-20000:]}, f)
|
|
55
|
+
os.replace(self.state_file + ".tmp", self.state_file)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.warning(f"保存状态失败: {e}")
|
|
58
|
+
|
|
59
|
+
def _is_dup(self, event_id: str) -> bool:
|
|
60
|
+
if not event_id:
|
|
61
|
+
return False
|
|
62
|
+
if event_id in self._seen:
|
|
63
|
+
return True
|
|
64
|
+
self._seen.add(event_id)
|
|
65
|
+
if len(self._seen) % 200 == 0:
|
|
66
|
+
self._save_state()
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
async def _connect(self):
|
|
70
|
+
self.rds = aioredis.from_url(self.redis_url, decode_responses=True)
|
|
71
|
+
try:
|
|
72
|
+
await self.rds.xgroup_create(
|
|
73
|
+
self.stream_key, self.group_name, id="$", mkstream=True
|
|
74
|
+
)
|
|
75
|
+
logger.info(f"创建 group {self.group_name} (从最新开始)")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
if "BUSYGROUP" in str(e):
|
|
78
|
+
logger.info(f"group {self.group_name} 已存在")
|
|
79
|
+
else:
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
async def listen(self, handler: MetaHandler):
|
|
83
|
+
await self._connect()
|
|
84
|
+
logger.info(
|
|
85
|
+
f"[{self.consumer_name}] 开始监听 stream={self.stream_key} group={self.group_name}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
while not self._stop:
|
|
89
|
+
try:
|
|
90
|
+
resp = await self.rds.xreadgroup(
|
|
91
|
+
groupname=self.group_name,
|
|
92
|
+
consumername=self.consumer_name,
|
|
93
|
+
streams={self.stream_key: ">"},
|
|
94
|
+
count=self.batch,
|
|
95
|
+
block=self.block_ms,
|
|
96
|
+
)
|
|
97
|
+
if not resp:
|
|
98
|
+
continue
|
|
99
|
+
for _stream, entries in resp:
|
|
100
|
+
for entry_id, fields in entries:
|
|
101
|
+
await self._dispatch(entry_id, fields, handler)
|
|
102
|
+
|
|
103
|
+
except asyncio.CancelledError:
|
|
104
|
+
break
|
|
105
|
+
except asyncio.TimeoutError:
|
|
106
|
+
continue
|
|
107
|
+
except redis.exceptions.TimeoutError:
|
|
108
|
+
continue
|
|
109
|
+
except redis.exceptions.ConnectionError as e:
|
|
110
|
+
logger.warning(f"Redis 连接异常,1 秒后重连: {e}")
|
|
111
|
+
await asyncio.sleep(1)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.error(f"循环异常: {e}")
|
|
114
|
+
await asyncio.sleep(1)
|
|
115
|
+
|
|
116
|
+
self._save_state()
|
|
117
|
+
if self.rds:
|
|
118
|
+
await self.rds.close()
|
|
119
|
+
logger.info(f"[{self.consumer_name}] 已停止")
|
|
120
|
+
|
|
121
|
+
async def _dispatch(self, entry_id, fields, handler: MetaHandler):
|
|
122
|
+
try:
|
|
123
|
+
payload = fields.get("payload")
|
|
124
|
+
if not payload:
|
|
125
|
+
await self.rds.xack(self.stream_key, self.group_name, entry_id)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
meta = json.loads(payload)
|
|
129
|
+
event_id = meta.get("event_id", "")
|
|
130
|
+
|
|
131
|
+
if self._is_dup(event_id):
|
|
132
|
+
await self.rds.xack(self.stream_key, self.group_name, entry_id)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
await handler(meta)
|
|
137
|
+
await self.rds.xack(self.stream_key, self.group_name, entry_id)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.exception(f"handler 处理失败 event_id={event_id}: {e}")
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(f"派发失败 entry={entry_id}: {e}")
|
|
142
|
+
|
|
143
|
+
def stop(self):
|
|
144
|
+
self._stop = True
|
consumer_sdk/snapshot.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# consumer_sdk/snapshot.py
|
|
2
|
+
"""
|
|
3
|
+
消费者侧:读 Redis 里的频道快照,格式化成可读文本或 embed。
|
|
4
|
+
|
|
5
|
+
分页策略:
|
|
6
|
+
- 用 code block (```...```) 包裹内容
|
|
7
|
+
- 按"频道 + 子区"为整体块分页
|
|
8
|
+
- 以"整行"为最小单位,绝不切字符
|
|
9
|
+
- 每条 embed 都有 title(第 1 条也带序号 (1),后续 (N))
|
|
10
|
+
- 取消零宽空格(code block 里 # 不触发 Markdown)
|
|
11
|
+
|
|
12
|
+
过滤:
|
|
13
|
+
- thread_max_age_days:只显示"最近 N 天有活动"的子区
|
|
14
|
+
|
|
15
|
+
多 reader 支持:
|
|
16
|
+
- fetch_snapshot(rds, reader_id="reader-01") 读 reader-01:* 前缀的 key
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import datetime
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
EMBED_DESC_LIMIT = 4096
|
|
24
|
+
CODE_BLOCK_OVERHEAD = 8
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _utf16_len(s: str) -> int:
|
|
28
|
+
length = 0
|
|
29
|
+
for ch in s:
|
|
30
|
+
if ord(ch) > 0xFFFF:
|
|
31
|
+
length += 2
|
|
32
|
+
else:
|
|
33
|
+
length += 1
|
|
34
|
+
return length
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_thread_active(t: dict, max_age_days: int) -> bool:
|
|
38
|
+
"""
|
|
39
|
+
判断子区是否"最近 N 天有活动"。
|
|
40
|
+
- max_age_days <= 0 → 不过滤
|
|
41
|
+
- last_message_at 缺失 → 用 created_at 兜底
|
|
42
|
+
- 都没有 → 不过滤(保守)
|
|
43
|
+
"""
|
|
44
|
+
if max_age_days <= 0:
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
last_msg_at = t.get("last_message_at")
|
|
48
|
+
if last_msg_at is None:
|
|
49
|
+
last_msg_at = t.get("created_at") or t.get("thread_created_at")
|
|
50
|
+
if last_msg_at is None:
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
age_days = (time.time() - float(last_msg_at)) / 86400.0
|
|
55
|
+
return age_days <= max_age_days
|
|
56
|
+
except Exception:
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def fetch_snapshot(rds, reader_id: str = "reader-01") -> dict:
|
|
61
|
+
"""
|
|
62
|
+
从 Redis 读频道快照。
|
|
63
|
+
|
|
64
|
+
参数:
|
|
65
|
+
rds: redis 客户端
|
|
66
|
+
reader_id: reader 标识,用于拼 key 前缀
|
|
67
|
+
比如 "reader-01" → 读 "reader-01:channels" 等
|
|
68
|
+
|
|
69
|
+
返回:
|
|
70
|
+
{
|
|
71
|
+
"guilds": {...},
|
|
72
|
+
"categories": {...},
|
|
73
|
+
"channels": {...},
|
|
74
|
+
"updated_at": float 或 None,
|
|
75
|
+
}
|
|
76
|
+
"""
|
|
77
|
+
guilds_raw = await rds.hgetall(f"{reader_id}:guilds")
|
|
78
|
+
cats_raw = await rds.hgetall(f"{reader_id}:categories")
|
|
79
|
+
chans_raw = await rds.hgetall(f"{reader_id}:channels")
|
|
80
|
+
ts = await rds.get(f"{reader_id}:snapshot:updated_at")
|
|
81
|
+
|
|
82
|
+
guilds = {}
|
|
83
|
+
for gid, j in guilds_raw.items():
|
|
84
|
+
try:
|
|
85
|
+
guilds[gid] = json.loads(j)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
cats = {}
|
|
90
|
+
for cid, j in cats_raw.items():
|
|
91
|
+
try:
|
|
92
|
+
cats[cid] = json.loads(j)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
chans = {}
|
|
97
|
+
for cid, j in chans_raw.items():
|
|
98
|
+
try:
|
|
99
|
+
chans[cid] = json.loads(j)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"guilds": guilds,
|
|
105
|
+
"categories": cats,
|
|
106
|
+
"channels": chans,
|
|
107
|
+
"updated_at": float(ts) if ts else None,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _channel_icon(channel_type: str) -> str:
|
|
112
|
+
if channel_type == "forum":
|
|
113
|
+
return "📋"
|
|
114
|
+
if channel_type == "voice":
|
|
115
|
+
return "🔊"
|
|
116
|
+
if channel_type == "announcement":
|
|
117
|
+
return "📢"
|
|
118
|
+
if channel_type == "stage":
|
|
119
|
+
return "🎙️"
|
|
120
|
+
return "#"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _make_channel_header(c: dict, indent: str = "") -> str:
|
|
124
|
+
cid = c["id"]
|
|
125
|
+
cname = c["name"]
|
|
126
|
+
ctype = c.get("channel_type", "")
|
|
127
|
+
icon = _channel_icon(ctype)
|
|
128
|
+
return f"{indent}{icon} {cname}({cid})"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _make_thread_line(t: dict, indent: str = "") -> str:
|
|
132
|
+
arch = " [已归档]" if t.get("archived") else ""
|
|
133
|
+
return f"{indent} └─ {t['name']}({t['id']}){arch}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_items(snapshot: dict, thread_max_age_days: int = 0) -> list:
|
|
137
|
+
"""
|
|
138
|
+
生成所有"可独立分页的条目"。
|
|
139
|
+
|
|
140
|
+
thread_max_age_days:
|
|
141
|
+
0 或负数 → 不过滤
|
|
142
|
+
> 0 → 只显示"最近 N 天有活动"的子区
|
|
143
|
+
"""
|
|
144
|
+
items = []
|
|
145
|
+
|
|
146
|
+
for gid, g in snapshot["guilds"].items():
|
|
147
|
+
gname = g.get("name", "?")
|
|
148
|
+
items.append({
|
|
149
|
+
"type": "text",
|
|
150
|
+
"text": f"🏠 {gname}({gid}){g.get('member_count','?')} 成员",
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
all_chans = [
|
|
154
|
+
c for c in snapshot["channels"].values()
|
|
155
|
+
if c.get("guild_id") == gid
|
|
156
|
+
]
|
|
157
|
+
mains = [c for c in all_chans if not c.get("is_thread")]
|
|
158
|
+
threads = [c for c in all_chans if c.get("is_thread")]
|
|
159
|
+
|
|
160
|
+
threads_by_parent = {}
|
|
161
|
+
skipped_threads = 0
|
|
162
|
+
for t in threads:
|
|
163
|
+
# ★ 过滤"不活跃"的子区
|
|
164
|
+
if not _is_thread_active(t, thread_max_age_days):
|
|
165
|
+
skipped_threads += 1
|
|
166
|
+
continue
|
|
167
|
+
pid = t.get("parent_id") or "__orphan__"
|
|
168
|
+
threads_by_parent.setdefault(pid, []).append(t)
|
|
169
|
+
|
|
170
|
+
if skipped_threads > 0:
|
|
171
|
+
print(f"[快照格式] {gname}: 跳过 {skipped_threads} 个不活跃子区")
|
|
172
|
+
|
|
173
|
+
no_cat = [c for c in mains if not c.get("category_id")]
|
|
174
|
+
with_cat = [c for c in mains if c.get("category_id")]
|
|
175
|
+
|
|
176
|
+
no_cat_sorted = sorted(
|
|
177
|
+
no_cat,
|
|
178
|
+
key=lambda x: x.get("position") if x.get("position") is not None else 999999
|
|
179
|
+
)
|
|
180
|
+
for c in no_cat_sorted:
|
|
181
|
+
items.append(_make_channel_item(c, threads_by_parent, indent=""))
|
|
182
|
+
|
|
183
|
+
by_cat = {}
|
|
184
|
+
for c in with_cat:
|
|
185
|
+
by_cat.setdefault(c["category_id"], []).append(c)
|
|
186
|
+
|
|
187
|
+
cat_list = []
|
|
188
|
+
for cat_id, cat_items in by_cat.items():
|
|
189
|
+
cat = snapshot["categories"].get(cat_id, {})
|
|
190
|
+
cat_pos = cat.get("position")
|
|
191
|
+
if cat_pos is None:
|
|
192
|
+
cat_pos = 999998
|
|
193
|
+
cat_name = cat.get("name")
|
|
194
|
+
if not cat_name:
|
|
195
|
+
for c in cat_items:
|
|
196
|
+
if c.get("category_name"):
|
|
197
|
+
cat_name = c["category_name"]
|
|
198
|
+
break
|
|
199
|
+
if not cat_name:
|
|
200
|
+
cat_name = f"分类 {cat_id}"
|
|
201
|
+
cat_list.append((cat_pos, cat_name, cat_items))
|
|
202
|
+
cat_list.sort(key=lambda x: x[0])
|
|
203
|
+
|
|
204
|
+
for cat_pos, cat_name, cat_items in cat_list:
|
|
205
|
+
items.append({"type": "text", "text": f"▾ {cat_name}"})
|
|
206
|
+
cat_items_sorted = sorted(
|
|
207
|
+
cat_items,
|
|
208
|
+
key=lambda x: x.get("position") if x.get("position") is not None else 999999
|
|
209
|
+
)
|
|
210
|
+
for c in cat_items_sorted:
|
|
211
|
+
items.append(_make_channel_item(c, threads_by_parent, indent=" "))
|
|
212
|
+
|
|
213
|
+
orphans = threads_by_parent.get("__orphan__", [])
|
|
214
|
+
if orphans:
|
|
215
|
+
items.append({"type": "text", "text": "▾ 孤儿子区"})
|
|
216
|
+
for t in sorted(orphans, key=lambda x: x.get("thread_created_at") or 0, reverse=True):
|
|
217
|
+
items.append({"type": "text", "text": _make_thread_line(t, " ")})
|
|
218
|
+
|
|
219
|
+
items.append({"type": "text", "text": ""})
|
|
220
|
+
|
|
221
|
+
return items
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _make_channel_item(c: dict, threads_by_parent: dict, indent: str = "") -> dict:
|
|
225
|
+
cid = c["id"]
|
|
226
|
+
header = _make_channel_header(c, indent)
|
|
227
|
+
children = []
|
|
228
|
+
child_threads = threads_by_parent.get(cid, [])
|
|
229
|
+
child_threads_sorted = sorted(
|
|
230
|
+
child_threads,
|
|
231
|
+
key=lambda x: x.get("thread_created_at") or 0,
|
|
232
|
+
reverse=True,
|
|
233
|
+
)
|
|
234
|
+
for t in child_threads_sorted:
|
|
235
|
+
children.append(_make_thread_line(t, indent))
|
|
236
|
+
return {
|
|
237
|
+
"type": "channel",
|
|
238
|
+
"header": header,
|
|
239
|
+
"indent": indent,
|
|
240
|
+
"children": children,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def format_snapshot(snapshot: dict, max_chars: int = 1900,
|
|
245
|
+
thread_max_age_days: int = 0) -> list:
|
|
246
|
+
lines = ["**📋 服务器频道清单**"]
|
|
247
|
+
if snapshot["updated_at"]:
|
|
248
|
+
ts = datetime.datetime.fromtimestamp(snapshot["updated_at"])
|
|
249
|
+
lines.append(f"更新时间:{ts}")
|
|
250
|
+
lines.append("")
|
|
251
|
+
|
|
252
|
+
for item in _build_items(snapshot, thread_max_age_days=thread_max_age_days):
|
|
253
|
+
if item["type"] == "text":
|
|
254
|
+
lines.append(item["text"])
|
|
255
|
+
else:
|
|
256
|
+
lines.append(item["header"])
|
|
257
|
+
lines.extend(item["children"])
|
|
258
|
+
|
|
259
|
+
chunks = []
|
|
260
|
+
cur = ""
|
|
261
|
+
for line in lines:
|
|
262
|
+
if len(cur) + len(line) + 1 > max_chars:
|
|
263
|
+
chunks.append(cur)
|
|
264
|
+
cur = line + "\n"
|
|
265
|
+
else:
|
|
266
|
+
cur += line + "\n"
|
|
267
|
+
if cur:
|
|
268
|
+
chunks.append(cur)
|
|
269
|
+
return chunks
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def format_snapshot_as_embeds(snapshot: dict, title: str = "📋 服务器频道清单",
|
|
273
|
+
thread_max_age_days: int = 0) -> list:
|
|
274
|
+
header_lines = []
|
|
275
|
+
if snapshot["updated_at"]:
|
|
276
|
+
ts = datetime.datetime.fromtimestamp(snapshot["updated_at"])
|
|
277
|
+
header_lines.append(f"更新时间:{ts}")
|
|
278
|
+
header_lines.append("")
|
|
279
|
+
|
|
280
|
+
items = _build_items(snapshot, thread_max_age_days=thread_max_age_days)
|
|
281
|
+
|
|
282
|
+
MAX_CONTENT = EMBED_DESC_LIMIT - CODE_BLOCK_OVERHEAD
|
|
283
|
+
|
|
284
|
+
embeds = []
|
|
285
|
+
current_lines = list(header_lines)
|
|
286
|
+
page_num = 1
|
|
287
|
+
|
|
288
|
+
def _content_utf16(lines: list) -> int:
|
|
289
|
+
if not lines:
|
|
290
|
+
return 0
|
|
291
|
+
return _utf16_len("\n".join(lines))
|
|
292
|
+
|
|
293
|
+
def flush_page():
|
|
294
|
+
nonlocal page_num, current_lines
|
|
295
|
+
if not current_lines:
|
|
296
|
+
return
|
|
297
|
+
content = "\n".join(current_lines)
|
|
298
|
+
desc = "```\n" + content + "\n```"
|
|
299
|
+
# ★ 所有页都带序号(包括第 1 页)
|
|
300
|
+
title_text = f"{title}({page_num})"
|
|
301
|
+
embeds.append({
|
|
302
|
+
"title": title_text[:256],
|
|
303
|
+
"description": desc,
|
|
304
|
+
"color": 0x5865F2,
|
|
305
|
+
})
|
|
306
|
+
page_num += 1
|
|
307
|
+
current_lines = []
|
|
308
|
+
|
|
309
|
+
def try_append(line: str):
|
|
310
|
+
nonlocal current_lines
|
|
311
|
+
candidate_lines = current_lines + [line]
|
|
312
|
+
if _content_utf16(candidate_lines) > MAX_CONTENT:
|
|
313
|
+
flush_page()
|
|
314
|
+
current_lines = [line]
|
|
315
|
+
else:
|
|
316
|
+
current_lines.append(line)
|
|
317
|
+
|
|
318
|
+
for item in items:
|
|
319
|
+
if item["type"] == "text":
|
|
320
|
+
try_append(item["text"])
|
|
321
|
+
else:
|
|
322
|
+
header = item["header"]
|
|
323
|
+
children = item["children"]
|
|
324
|
+
|
|
325
|
+
block = [header] + children
|
|
326
|
+
candidate_lines = current_lines + block
|
|
327
|
+
if _content_utf16(candidate_lines) <= MAX_CONTENT:
|
|
328
|
+
current_lines.extend(block)
|
|
329
|
+
else:
|
|
330
|
+
try_append(header)
|
|
331
|
+
for c in children:
|
|
332
|
+
try_append(c)
|
|
333
|
+
|
|
334
|
+
if current_lines:
|
|
335
|
+
flush_page()
|
|
336
|
+
|
|
337
|
+
return embeds
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def format_snapshot_summary(snapshot: dict) -> str:
|
|
341
|
+
lines = ["**📋 服务器频道快照**"]
|
|
342
|
+
for gid, g in snapshot["guilds"].items():
|
|
343
|
+
chans = [c for c in snapshot["channels"].values() if c.get("guild_id") == gid]
|
|
344
|
+
mains = [c for c in chans if not c.get("is_thread")]
|
|
345
|
+
threads = [c for c in chans if c.get("is_thread")]
|
|
346
|
+
lines.append(
|
|
347
|
+
f"- **{g['name']}**(`{gid}`):"
|
|
348
|
+
f"{len(mains)} 频道、{len(threads)} 子区"
|
|
349
|
+
)
|
|
350
|
+
lines.append("")
|
|
351
|
+
lines.append("完整清单请用 `tools/list_channels.py` 查看")
|
|
352
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
consumer_sdk/__init__.py,sha256=YDoc7-PtxjiV9HlsSc1HSGN39qkI_g2fQhMIy9b54Hk,240
|
|
2
|
+
consumer_sdk/consumer.py,sha256=FKu0L0WVbtPYAa1LEeEdRqEHTW7V9_lzrs2S18Vi7po,4984
|
|
3
|
+
consumer_sdk/snapshot.py,sha256=DK8GKLMPd_D-Czi9b2qUK871WZW_kjOVfn6xtuxeejQ,10769
|
|
4
|
+
consumer_sdk-1.0.0.dist-info/METADATA,sha256=7359igRfJBokwbRHwBRVirdcIEMwnX3ky-jb1QLrugk,110
|
|
5
|
+
consumer_sdk-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
consumer_sdk-1.0.0.dist-info/top_level.txt,sha256=f-tgdcDvoE5nrFqYVG5PoRaSzb6reBSwvPswNKP7ogY,13
|
|
7
|
+
consumer_sdk-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
consumer_sdk
|