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.
@@ -0,0 +1,9 @@
1
+ from .consumer import DiscordEventConsumer
2
+ from .snapshot import fetch_snapshot, format_snapshot, format_snapshot_summary
3
+
4
+ __all__ = [
5
+ "DiscordEventConsumer",
6
+ "fetch_snapshot",
7
+ "format_snapshot",
8
+ "format_snapshot_summary",
9
+ ]
@@ -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
@@ -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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: consumer-sdk
3
+ Version: 1.0.0
4
+ Requires-Dist: redis>=5.0
5
+ Requires-Dist: aiohttp>=3.9
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ consumer_sdk