tiebameow 0.2.8__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,990 @@
1
+ from __future__ import annotations
2
+
3
+ import operator
4
+ import re
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta
7
+ from enum import StrEnum, unique
8
+ from typing import TYPE_CHECKING, Any, Literal, cast
9
+
10
+ import pyparsing as pp
11
+
12
+ from ..schemas.rules import (
13
+ Actions,
14
+ ActionType,
15
+ Condition,
16
+ FieldType,
17
+ LogicType,
18
+ OperatorType,
19
+ RuleGroup,
20
+ RuleNode,
21
+ )
22
+ from ..utils.time_utils import now_with_tz
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Generator
26
+
27
+
28
+ @unique
29
+ class PunctuationType(StrEnum):
30
+ """
31
+ 标点符号类型枚举。
32
+
33
+ 用于词法分析中的辅助符号,如括号、逗号等。
34
+ """
35
+
36
+ LPAR = "LPAR"
37
+ RPAR = "RPAR"
38
+ LBRACK = "LBRACK"
39
+ RBRACK = "RBRACK"
40
+ COMMA = "COMMA"
41
+ ASSIGN = "ASSIGN"
42
+ COLON = "COLON"
43
+ ACTION_PREFIX = "ACTION_PREFIX"
44
+
45
+
46
+ @unique
47
+ class BooleanType(StrEnum):
48
+ """
49
+ 布尔值类型枚举。
50
+
51
+ 用于统一管理 DSL/CNL 中的真假值表示。
52
+ """
53
+
54
+ TRUE = "TRUE"
55
+ FALSE = "FALSE"
56
+
57
+
58
+ class TokenMap[E: StrEnum]:
59
+ """
60
+ 双向映射容器:负责将枚举类型映射到自然语言 Token (单值或多值)。
61
+
62
+ 提供正向查找 (Enum -> Primary Token/List) 和 反向查找 (Token -> Enum)。
63
+ 主要用于解决多语言 (CNL/DSL) 对应的关键词解析问题。
64
+
65
+ Attributes:
66
+ _enum_to_tokens: 保存枚举到 Token 列表的正向映射。
67
+ _token_map: 保存 Token 到枚举的反向映射 (Token 统一转为小写存储)。
68
+ """
69
+
70
+ def __init__(self, mapping: dict[E, str | list[str]]) -> None:
71
+ """
72
+ 初始化 TokenMap。
73
+
74
+ Args:
75
+ mapping: 枚举到 Token 的映射字典,Token 可以是字符串或字符串列表。
76
+ """
77
+ self._enum_to_tokens: dict[E, list[str]] = {}
78
+ self._token_map: dict[str, E] = {}
79
+
80
+ for enum_key, tokens in mapping.items():
81
+ token_list = [tokens] if isinstance(tokens, str) else tokens
82
+ # 保存正向映射
83
+ self._enum_to_tokens[enum_key] = token_list
84
+ # 建立反向索引 (不论大小写,统一存小写以便查找时忽略大小写)
85
+ for t in token_list:
86
+ # 简单起见,如果配置中有冲突,后定义的覆盖先定义的
87
+ self._token_map[t.lower()] = enum_key
88
+
89
+ def get_tokens(self, key: E) -> list[str]:
90
+ """
91
+ 获取该枚举对应的所有 Token 列表。
92
+
93
+ Args:
94
+ key: 目标枚举值。
95
+
96
+ Returns:
97
+ list[str]: 该枚举对应的所有 Token 字符串列表。
98
+ """
99
+ return self._enum_to_tokens.get(key, [])
100
+
101
+ def get_primary_token(self, key: E) -> str:
102
+ """
103
+ 获取用于生成的首选 Token (列表第一个)。
104
+
105
+ Args:
106
+ key: 目标枚举值。
107
+
108
+ Returns:
109
+ str: 首选 Token。
110
+
111
+ Raises:
112
+ ValueError: 如果该枚举未定义任何 Token。
113
+ """
114
+ tokens = self.get_tokens(key)
115
+ if not tokens:
116
+ raise ValueError(f"No tokens defined for {key}")
117
+ return tokens[0]
118
+
119
+ def get_parser_element(self, key: E, caseless: bool = True) -> pp.ParserElement:
120
+ """
121
+ 获取用于解析的 pyparsing ParserElement (Literal 或 OneOf)。
122
+
123
+ Args:
124
+ key: 目标枚举值。
125
+ caseless: 是否忽略大小写,默认为 True。
126
+
127
+ Returns:
128
+ pp.ParserElement: 对应的 pyparsing 解析元素。
129
+ """
130
+ tokens = self.get_tokens(key)
131
+ if not tokens:
132
+ raise ValueError(f"No tokens configured for {key}")
133
+
134
+ # 按照长度降序排列,确保最长匹配优先 (例如 '>=' 优于 '>')
135
+ sorted_tokens = sorted(tokens, key=len, reverse=True)
136
+
137
+ if len(sorted_tokens) == 1:
138
+ return pp.CaselessLiteral(sorted_tokens[0]) if caseless else pp.Literal(sorted_tokens[0])
139
+ return pp.one_of(sorted_tokens, caseless=caseless)
140
+
141
+ def get_enum(self, token: str) -> E | None:
142
+ """
143
+ 根据 Token 反查枚举。
144
+
145
+ Args:
146
+ token: 输入的 Token 字符串。
147
+
148
+ Returns:
149
+ E | None: 对应的枚举值,如果未找到则返回 None。
150
+ """
151
+ return self._token_map.get(token.lower())
152
+
153
+ def get_all_tokens(self) -> list[str]:
154
+ """
155
+ 获取所有映射中的 Token 列表 (用于构建保留字)。
156
+
157
+ Returns:
158
+ list[str]: 所有已注册 Token 的列表。
159
+ """
160
+ return list(self._token_map.keys())
161
+
162
+ def get_all_enums(self) -> list[E]:
163
+ """
164
+ 获取所有已映射的枚举值。
165
+
166
+ Returns:
167
+ list[E]: 所有已配置 Token 的枚举列表。
168
+ """
169
+ return list(self._enum_to_tokens.keys())
170
+
171
+
172
+ @dataclass
173
+ class LangConfig:
174
+ """
175
+ 语言配置集,包含各类 Token 的映射关系。
176
+
177
+ 用于定义特定语言模式 (DSL 或 CNL) 下的所有词法规则配置。
178
+
179
+ Attributes:
180
+ fields: 字段 Token 映射。
181
+ operators: 操作符 Token 映射。
182
+ logic: 逻辑运算符 Token 映射。
183
+ actions: 动作 Token 映射。
184
+ punctuation: 标点符号 Token 映射。
185
+ """
186
+
187
+ fields: TokenMap[FieldType]
188
+ operators: TokenMap[OperatorType]
189
+ logic: TokenMap[LogicType]
190
+ actions: TokenMap[ActionType]
191
+ punctuation: TokenMap[PunctuationType]
192
+ booleans: TokenMap[BooleanType]
193
+
194
+ @classmethod
195
+ def create(
196
+ cls,
197
+ fields: dict[FieldType, str | list[str]],
198
+ operators: dict[OperatorType, str | list[str]],
199
+ logic: dict[LogicType, str | list[str]],
200
+ actions: dict[ActionType, str | list[str]],
201
+ punctuation: dict[PunctuationType, str | list[str]],
202
+ booleans: dict[BooleanType, str | list[str]],
203
+ ) -> LangConfig:
204
+ """
205
+ 创建语言配置的工厂方法。
206
+
207
+ Args:
208
+ fields: 字段映射字典。
209
+ operators: 操作符映射字典。
210
+ logic: 逻辑词映射字典。
211
+ actions: 动作映射字典。
212
+ punctuation: 标点映射字典。
213
+ booleans: 布尔值映射字典。
214
+
215
+ Returns:
216
+ LangConfig: 初始化的语言配置对象。
217
+ """
218
+ return cls(
219
+ fields=TokenMap(fields),
220
+ operators=TokenMap(operators),
221
+ logic=TokenMap(logic),
222
+ actions=TokenMap(actions),
223
+ punctuation=TokenMap(punctuation),
224
+ booleans=TokenMap(booleans),
225
+ )
226
+
227
+
228
+ COMMON_PUNCT: dict[PunctuationType, str | list[str]] = {
229
+ PunctuationType.LPAR: ["(", "("],
230
+ PunctuationType.RPAR: [")", ")"],
231
+ PunctuationType.LBRACK: ["[", "【"],
232
+ PunctuationType.RBRACK: ["]", "】"],
233
+ PunctuationType.COMMA: [",", ",", "、"],
234
+ PunctuationType.ASSIGN: ["=", ":", ":"],
235
+ PunctuationType.COLON: [":", ":"],
236
+ PunctuationType.ACTION_PREFIX: ["DO", "执行", "ACT"],
237
+ }
238
+
239
+ DSL_CONFIG = LangConfig.create(
240
+ fields={k: k.value for k in FieldType},
241
+ operators={
242
+ OperatorType.CONTAINS: "contains",
243
+ OperatorType.NOT_CONTAINS: "not_contains",
244
+ OperatorType.REGEX: "match",
245
+ OperatorType.NOT_REGEX: "not_match",
246
+ OperatorType.EQ: "==",
247
+ OperatorType.NEQ: "!=",
248
+ OperatorType.GT: ">",
249
+ OperatorType.LT: "<",
250
+ OperatorType.GTE: ">=",
251
+ OperatorType.LTE: "<=",
252
+ OperatorType.IN: "in",
253
+ OperatorType.NOT_IN: "not_in",
254
+ },
255
+ logic={
256
+ LogicType.AND: "AND",
257
+ LogicType.OR: "OR",
258
+ LogicType.NOT: "NOT",
259
+ },
260
+ actions={k: k.value for k in ActionType},
261
+ punctuation=COMMON_PUNCT,
262
+ booleans={
263
+ BooleanType.TRUE: "true",
264
+ BooleanType.FALSE: "false",
265
+ },
266
+ )
267
+
268
+ CNL_CONFIG = LangConfig.create(
269
+ fields={
270
+ FieldType.TITLE: "标题",
271
+ FieldType.IS_GOOD: ["加精", "精华帖", "精华贴"],
272
+ FieldType.IS_TOP: ["置顶", "置顶帖", "置顶贴"],
273
+ FieldType.IS_SHARE: ["分享", "分享贴", "分享帖"],
274
+ FieldType.IS_HIDE: ["隐藏", "隐藏贴", "隐藏帖"],
275
+ FieldType.TEXT: ["内容", "文本"],
276
+ FieldType.FULL_TEXT: ["完整内容", "全文"],
277
+ FieldType.LEVEL: ["等级", "用户等级"],
278
+ FieldType.USER_ID: "user_id",
279
+ FieldType.PORTRAIT: "portrait",
280
+ FieldType.USER_NAME: "用户名",
281
+ FieldType.NICK_NAME: "昵称",
282
+ FieldType.AGREE_NUM: ["点赞", "点赞数"],
283
+ FieldType.DISAGREE_NUM: ["点踩", "点踩数"],
284
+ FieldType.REPLY_NUM: ["回复", "回复数"],
285
+ FieldType.VIEW_NUM: ["浏览", "浏览数", "浏览量"],
286
+ FieldType.SHARE_NUM: ["分享", "分享数"],
287
+ FieldType.CREATE_TIME: ["创建时间", "发帖时间", "发贴时间", "发布时间"],
288
+ FieldType.LAST_TIME: ["最后回复时间"],
289
+ FieldType.SHARE_FNAME: ["分享来源吧名", "分享来源贴吧"],
290
+ FieldType.SHARE_FID: ["分享来源吧ID", "分享来源fid"],
291
+ FieldType.SHARE_TITLE: ["分享来源标题"],
292
+ FieldType.SHARE_TEXT: ["分享来源内容", "分享来源文本"],
293
+ },
294
+ operators={
295
+ OperatorType.CONTAINS: "包含",
296
+ OperatorType.NOT_CONTAINS: "不包含",
297
+ OperatorType.REGEX: "正则",
298
+ OperatorType.NOT_REGEX: "不匹配正则",
299
+ OperatorType.EQ: "等于",
300
+ OperatorType.NEQ: "不等于",
301
+ OperatorType.GT: "大于",
302
+ OperatorType.LT: "小于",
303
+ OperatorType.GTE: "大于等于",
304
+ OperatorType.LTE: "小于等于",
305
+ OperatorType.IN: "属于",
306
+ OperatorType.NOT_IN: "不属于",
307
+ },
308
+ logic={
309
+ LogicType.AND: ["并且", "且"],
310
+ LogicType.OR: ["或者", "或"],
311
+ LogicType.NOT: "非",
312
+ },
313
+ actions={
314
+ ActionType.DELETE: ["删除", "删贴", "删帖"],
315
+ ActionType.BAN: ["封禁"],
316
+ ActionType.NOTIFY: ["通知"],
317
+ },
318
+ punctuation=COMMON_PUNCT,
319
+ booleans={
320
+ BooleanType.TRUE: ["真", "是", "true", "True"],
321
+ BooleanType.FALSE: ["假", "否", "false", "False"],
322
+ },
323
+ )
324
+
325
+
326
+ class RuleEngineParser:
327
+ """
328
+ 基于规则的 DSL/CNL 解析引擎。
329
+
330
+ 该类负责解析特定语法的规则字符串(包括条件触发器和动作执行),
331
+ 将其转换为结构化的 RuleNode 和 Action 对象。
332
+ 支持 DSL (Domain Specific Language) 和 CNL (Controlled Natural Language) 两种模式,
333
+ 分别对应 开发者友好 和 用户友好 的语法风格。
334
+
335
+ Attributes:
336
+ _parsers: 缓存不同模式 (dsl/cnl) 下的 pyparsing 解析对象 (trigger_parser, action_parser)。
337
+ """
338
+
339
+ def __init__(self) -> None:
340
+ """
341
+ 初始化解析引擎。
342
+
343
+ 预先构建 DSL 和 CNL 的语法解析器以提高后续解析性能。
344
+ """
345
+ self._parsers: dict[str, tuple[pp.ParserElement, pp.ParserElement]] = {}
346
+ self._parsers["dsl"] = (self._build_trigger_grammar(DSL_CONFIG), self._build_action_grammar(DSL_CONFIG))
347
+ self._parsers["cnl"] = (self._build_trigger_grammar(CNL_CONFIG), self._build_action_grammar(CNL_CONFIG))
348
+
349
+ def _build_value_parser(self, cfg: LangConfig) -> pp.ParserElement:
350
+ """
351
+ 构建通用的值解析器。
352
+
353
+ 支持解析多种数据类型:
354
+ 1. 字符串:支持双引号、单引号及中文引号,支持转义字符。
355
+ 2. 数字:支持整数和浮点数。
356
+ 3. 布尔值:支持 true/false (不论大小写) 及 中文 真/假/是/否。
357
+ 4. 列表:支持形如 [1, "a"] 的列表结构。
358
+
359
+ Args:
360
+ cfg: 当前语言配置。
361
+
362
+ Returns:
363
+ pp.ParserElement: 值解析器元素。
364
+ """
365
+
366
+ def try_parse_datetime(val: str) -> datetime | None:
367
+ val = val.replace("T", " ").strip()
368
+ fmt_list = [
369
+ "%Y-%m-%d %H:%M:%S",
370
+ "%Y-%m-%d %H:%M",
371
+ "%Y-%m-%d",
372
+ "%Y年%m月%d日 %H时%M分%S秒",
373
+ "%Y年%m月%d日 %H时%M分",
374
+ "%Y年%m月%d日",
375
+ ]
376
+ for fmt in fmt_list:
377
+ try:
378
+ dt = datetime.strptime(val, fmt)
379
+ return dt.replace(tzinfo=now_with_tz().tzinfo)
380
+ except ValueError:
381
+ continue
382
+ return None
383
+
384
+ quoted_str = (
385
+ pp.QuotedString('"', esc_char="\\")
386
+ | pp.QuotedString("'", esc_char="\\")
387
+ | pp.QuotedString("“", end_quote_char="”", esc_char="\\")
388
+ | pp.QuotedString("‘", end_quote_char="’", esc_char="\\")
389
+ )
390
+ quoted_str.add_parse_action(lambda t: try_parse_datetime(cast("str", t[0])) or t[0])
391
+
392
+ # 1. ISO 8601 绝对时间 (YYYY-MM-DD HH:MM:SS) 与 中文日期格式
393
+ # 扩展支持由 Regex 直接捕获的无引号时间字符串
394
+ iso_pattern = r"\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?"
395
+ cn_pattern = r"\d{4}年\d{1,2}月\d{1,2}日(?:\s*\d{1,2}时\d{1,2}分(?:\d{1,2}秒)?)?"
396
+
397
+ # 组合模式,注意顺序
398
+ absolute_date_pattern = f"(?:{iso_pattern})|(?:{cn_pattern})"
399
+ iso_date = pp.Regex(absolute_date_pattern).set_name("absolute_date")
400
+
401
+ def parse_iso(t: Any) -> datetime | str:
402
+ val = str(t[0])
403
+ res = try_parse_datetime(val)
404
+ return res or val
405
+
406
+ iso_date.set_parse_action(parse_iso)
407
+
408
+ # 2. 相对时间处理函数
409
+ def parse_relative_time(amount: str | int, unit: str, direction: str = "ago") -> datetime:
410
+ """计算相对时间,例如 '3d' -> now - 3 days"""
411
+ now = now_with_tz()
412
+ val = int(amount)
413
+
414
+ delta_args = {}
415
+ if unit.lower() in ("d", "天"):
416
+ delta_args["days"] = val
417
+ elif unit.lower() in ("h", "小时", "时"):
418
+ delta_args["hours"] = val
419
+ elif unit.lower() in ("m", "分", "分钟"):
420
+ delta_args["minutes"] = val
421
+ elif unit.lower() in ("s", "秒"):
422
+ delta_args["seconds"] = val
423
+
424
+ delta = timedelta(**delta_args)
425
+
426
+ # 逻辑:'3天前' 或 '3d' (默认ago) -> now - delta
427
+ # 如果未来支持 '3天后' 可以判断 direction
428
+ return now - delta
429
+
430
+ # 匹配 DSL: 纯数字 + d/h/m/s (无空格)
431
+ dsl_unit = pp.one_of("d h m s", caseless=True)
432
+ relative_dsl = pp.Group(pp.Combine(pp.Word(pp.nums) + dsl_unit)).set_name("relative_dsl")
433
+
434
+ def action_dsl(t: Any) -> datetime:
435
+ # t[0] 是 Group 里的内容,如 "10d"
436
+ full_str = t[0][0]
437
+ unit = full_str[-1]
438
+ amount = full_str[:-1]
439
+ return parse_relative_time(amount, unit)
440
+
441
+ relative_dsl.set_parse_action(action_dsl)
442
+
443
+ # 匹配 CNL: 纯数字 + 中文单位 + (可选"前")
444
+ cnl_unit = pp.one_of("天 小时 时 分 分钟 秒")
445
+ # 允许数字和单位间有空格,允许后缀"前"
446
+ relative_cnl = pp.Group(pp.Word(pp.nums) + pp.Optional(pp.White()) + cnl_unit + pp.Optional("前")).set_name(
447
+ "relative_cnl"
448
+ )
449
+
450
+ def action_cnl(t: Any) -> datetime:
451
+ # t[0] 是一个 list,例如 ['3', '天'] 或 ['3', ' ', '天', '前']
452
+ # 取出数字和单位,忽略空格和后缀
453
+ parsed_list = t[0].as_list()
454
+ amount = parsed_list[0]
455
+ # 找到单位 (在 list 中找到属于 cnl_unit 候选词的项)
456
+ unit = next(item for item in parsed_list[1:] if item.strip() and item != "前")
457
+ return parse_relative_time(amount, unit)
458
+
459
+ relative_cnl.set_parse_action(action_cnl)
460
+
461
+ # 3. 关键字 NOW
462
+ now_keyword = pp.CaselessLiteral("NOW").set_parse_action(lambda: now_with_tz())
463
+
464
+ # 组合时间解析器
465
+ datetime_expr = iso_date | relative_dsl | relative_cnl | now_keyword
466
+
467
+ # 数字 (优先匹配浮点,再匹配整数)
468
+ number = pp.common.number.set_parse_action(operator.itemgetter(0))
469
+
470
+ # 布尔值支持
471
+ # 使用配置中的 TokenMap
472
+ bool_true = cfg.booleans.get_parser_element(BooleanType.TRUE, caseless=True)
473
+ bool_false = cfg.booleans.get_parser_element(BooleanType.FALSE, caseless=True)
474
+
475
+ # 获取所有真值的 Token 列表用于后续判断
476
+ true_values = set(cfg.booleans.get_tokens(BooleanType.TRUE))
477
+
478
+ boolean = (bool_true | bool_false).set_parse_action(lambda t: True if t[0] in true_values else False)
479
+
480
+ # 列表 [1, "a", 'b']
481
+ lbrack = cfg.punctuation.get_parser_element(PunctuationType.LBRACK, caseless=True)
482
+ rbrack = cfg.punctuation.get_parser_element(PunctuationType.RBRACK, caseless=True)
483
+ comma = cfg.punctuation.get_parser_element(PunctuationType.COMMA, caseless=True)
484
+
485
+ # boolean 必须放在 quoted_str 之前,或者作为独立分支
486
+ list_val = (
487
+ pp.Suppress(lbrack)
488
+ + pp.DelimitedList(boolean | datetime_expr | quoted_str | number, delim=comma)
489
+ + pp.Suppress(rbrack)
490
+ )
491
+ list_val.set_parse_action(lambda t: [t.as_list()])
492
+
493
+ return list_val | boolean | datetime_expr | quoted_str | number
494
+
495
+ def _build_identifier_parser(self, reserved_words: list[str]) -> pp.ParserElement:
496
+ """
497
+ 构建智能标识符解析器。
498
+
499
+ 关键逻辑:
500
+ 1. 允许 中文、字母、数字、下划线、点号。
501
+ 2. 实现 Lookahead 机制,排除包含 '保留字' 的匹配,防止 Greedy 匹配吞噬紧邻的操作符。
502
+ 例如确保 '贴吧ID等于' 被解析为 Identifier('贴吧ID') + Op('等于')。
503
+
504
+ Args:
505
+ reserved_words: 需要保留的关键词列表 (通常是操作符和逻辑词)。
506
+
507
+ Returns:
508
+ pp.ParserElement: 标识符解析器。
509
+ """
510
+ # 1. 构建 Lookahead Pattern (?!reserved)
511
+ # 按照长度排序,优先排除长的保留字
512
+ reserved_sorted = sorted(reserved_words, key=len, reverse=True)
513
+ # re.escape 确保特殊字符被转义
514
+ reserved_pattern = "|".join(map(re.escape, reserved_sorted))
515
+
516
+ # 2. 构建 Valid Char Pattern
517
+ # [a-zA-Z0-9._\u4e00-\u9fa5]
518
+ valid_chars_pattern = r"[a-zA-Z0-9._\u4e00-\u9fa5]"
519
+
520
+ # 3. 组合 Regex: (?:(?!reserved)valid_char)+
521
+ # 对每一个字符位置进行 Lookahead 检查,确保不开启保留字
522
+ regex_str = f"(?:(?!{reserved_pattern}){valid_chars_pattern})+"
523
+
524
+ # 忽略大小写
525
+ return pp.Regex(regex_str, flags=re.IGNORECASE)
526
+
527
+ def _build_trigger_grammar(self, cfg: LangConfig) -> pp.ParserElement:
528
+ """
529
+ 构建触发器规则的语法解析器。
530
+
531
+ 语法结构包含:
532
+ 1. 条件表达式:Field + Operator + Value。
533
+ 2. 逻辑组合:NOT, AND, OR 及 括号嵌套。
534
+ 3. 标识符识别:支持严格匹配 (Fields) 和 泛型匹配 (Identifier)。
535
+
536
+ Args:
537
+ cfg: 当前语言配置。
538
+
539
+ Returns:
540
+ pp.ParserElement: 完整的触发器语法解析器。
541
+ """
542
+ value = self._build_value_parser(cfg)
543
+
544
+ # 1. Operators
545
+ all_op_tokens = []
546
+ for op in cfg.operators.get_all_enums():
547
+ all_op_tokens.extend(cfg.operators.get_tokens(op))
548
+ all_op_tokens.sort(key=len, reverse=True)
549
+ op_parser_combined = pp.one_of(all_op_tokens, caseless=True).set_name("operator")
550
+
551
+ # 2. Strict Fields (Priority for non-greedy matching)
552
+ all_field_tokens = []
553
+ for f in cfg.fields.get_all_enums():
554
+ all_field_tokens.extend(cfg.fields.get_tokens(f))
555
+ all_field_tokens.sort(key=len, reverse=True)
556
+ strict_field_parser = pp.one_of(all_field_tokens, caseless=True).set_name("field_strict")
557
+
558
+ # 3. Generic Identifier (Fallback)
559
+ # 准备保留字 (用于切分无空格文本)
560
+ ops = cfg.operators.get_all_tokens()
561
+ logics = cfg.logic.get_all_tokens()
562
+ reserved_list = ops + logics
563
+
564
+ generic_identifier = self._build_identifier_parser(reserved_list)
565
+
566
+ # 优先匹配已知字段 (Strict),解决 "内容包含" 被 Word 贪婪吞噬的问题
567
+ identifier = strict_field_parser | generic_identifier
568
+
569
+ # 4. 组合 Condition: Field + Op + Value
570
+ # Group 使得结果结构化
571
+ condition = pp.Group(identifier("field") + op_parser_combined("op") + value("val"))
572
+
573
+ # 5. 构建递归逻辑树
574
+ LPAR = cfg.punctuation.get_parser_element(PunctuationType.LPAR)
575
+ RPAR = cfg.punctuation.get_parser_element(PunctuationType.RPAR)
576
+ NOT_L = cfg.logic.get_parser_element(LogicType.NOT)
577
+ AND_L = cfg.logic.get_parser_element(LogicType.AND)
578
+ OR_L = cfg.logic.get_parser_element(LogicType.OR)
579
+
580
+ trigger_expr = pp.infix_notation(
581
+ condition,
582
+ [
583
+ (NOT_L, 1, pp.opAssoc.RIGHT),
584
+ (AND_L, 2, pp.opAssoc.LEFT),
585
+ (OR_L, 2, pp.opAssoc.LEFT),
586
+ ],
587
+ lpar=LPAR,
588
+ rpar=RPAR,
589
+ )
590
+ return trigger_expr
591
+
592
+ def _build_action_grammar(self, cfg: LangConfig) -> pp.ParserElement:
593
+ """
594
+ 构建动作列表的语法解析器。
595
+
596
+ 语法结构包含:
597
+ 1. 动作调用:ActionName(key=value, key2=value2)。
598
+ 2. 动作列表:多个动作以逗号分隔。
599
+ 3. 前缀支持:可选的 DO/Act 前缀。
600
+
601
+ Args:
602
+ cfg: 当前语言配置。
603
+
604
+ Returns:
605
+ pp.ParserElement: 动作语法解析器。
606
+ """
607
+ identifier = pp.Word(pp.alphanums + "_") # 参数名通常是英文 key
608
+ value = self._build_value_parser(cfg)
609
+
610
+ LPAR = cfg.punctuation.get_parser_element(PunctuationType.LPAR)
611
+ RPAR = cfg.punctuation.get_parser_element(PunctuationType.RPAR)
612
+ ASSIGN = cfg.punctuation.get_parser_element(PunctuationType.ASSIGN)
613
+ COMMA = cfg.punctuation.get_parser_element(PunctuationType.COMMA)
614
+ COLON = cfg.punctuation.get_parser_element(PunctuationType.COLON)
615
+
616
+ # key=val
617
+ param_pair = pp.Group(identifier("key") + pp.Suppress(ASSIGN) + value("val"))
618
+ params = pp.Dict(pp.Optional(pp.DelimitedList(param_pair, delim=COMMA)))
619
+
620
+ # type(params)
621
+ all_action_tokens = []
622
+ for act in cfg.actions.get_all_enums():
623
+ all_action_tokens.extend(cfg.actions.get_tokens(act))
624
+ all_action_tokens.sort(key=len, reverse=True)
625
+ action_type = pp.one_of(all_action_tokens, caseless=True)
626
+
627
+ action_call = pp.Group(action_type("type") + pp.Suppress(LPAR) + params("params") + pp.Suppress(RPAR))
628
+
629
+ # prefix: action, action
630
+ prefix_keywords = cfg.punctuation.get_parser_element(PunctuationType.ACTION_PREFIX)
631
+ # prefix 可以是 DO 或 DO:
632
+ prefix = pp.Suppress(prefix_keywords + pp.Optional(COLON))
633
+
634
+ action_list = pp.Optional(prefix) + pp.DelimitedList(action_call, delim=COMMA)
635
+ return action_list
636
+
637
+ def _to_rule_node(self, parsed_item: Any, cfg: LangConfig) -> RuleNode:
638
+ """
639
+ 将 pyparsing 的解析结果递归转换为 Pydantic 定义的 RuleNode 模型。
640
+
641
+ 处理单一条件 (Condition) 以及复合逻辑组 (RuleGroup),
642
+ 并负责将 DSL/CNL 中的 Token 映射回标准的枚举值。
643
+
644
+ Args:
645
+ parsed_item: pyparsing 返回的解析树节点。
646
+ cfg: 当前语言配置。
647
+
648
+ Returns:
649
+ RuleNode: 转换后的规则节点 (Condition 或 RuleGroup)。
650
+
651
+ Raises:
652
+ ValueError: 如果解析结果包含未知的操作符或逻辑词。
653
+ """
654
+ # 1. Condition (Group['field', 'op', 'val'])
655
+ if "field" in parsed_item:
656
+ raw_field = parsed_item["field"]
657
+ raw_op = parsed_item["op"]
658
+ val = parsed_item["val"]
659
+ if isinstance(val, pp.ParseResults):
660
+ val = val.as_list()
661
+ if len(val) == 1:
662
+ val = val[0]
663
+
664
+ # 兼容处理: one_of 有时返回 list
665
+ if isinstance(raw_op, list | pp.ParseResults):
666
+ raw_op = raw_op[0]
667
+ if isinstance(raw_field, list | pp.ParseResults):
668
+ raw_field = raw_field[0]
669
+
670
+ # 强制转换为字符串以满足类型检查
671
+ raw_field_str = str(raw_field)
672
+ raw_op_str = str(raw_op)
673
+
674
+ # 映射回标准枚举
675
+ field_enum = cfg.fields.get_enum(raw_field_str)
676
+ if not field_enum:
677
+ # 严格模式:如果配置没覆盖到,且无法识别为标准字段,则报错
678
+ # 之前允许 raw_field_str 回退,现在为了安全性禁止未知字段
679
+ raise ValueError(f"Unknown field: {raw_field_str}")
680
+
681
+ op_enum = cfg.operators.get_enum(raw_op_str)
682
+ # 必须找到对应的 Op,否则可能是非法输入
683
+ if not op_enum:
684
+ raise ValueError(f"Unknown operator: {raw_op}")
685
+
686
+ return Condition(field=field_enum, operator=op_enum, value=val)
687
+
688
+ # 2. RuleGroup (List: [Node, Logic, Node...])
689
+ if isinstance(parsed_item, list) or hasattr(parsed_item, "as_list"):
690
+ items = parsed_item
691
+
692
+ # Handle ( A ) - sometimes retained by infix_notation
693
+ lpar_tokens = cfg.punctuation.get_tokens(PunctuationType.LPAR)
694
+ rpar_tokens = cfg.punctuation.get_tokens(PunctuationType.RPAR)
695
+ if len(items) == 3 and items[0] in lpar_tokens and items[-1] in rpar_tokens:
696
+ return self._to_rule_node(items[1], cfg)
697
+
698
+ # NOT A
699
+ # 注意: 这里比较 tokens
700
+ # items[0] 是 parsing 出来的 token, 需反查 LogicEnum
701
+ if len(items) == 2:
702
+ token0 = items[0]
703
+ logic_enum = cfg.logic.get_enum(token0)
704
+ if logic_enum == LogicType.NOT:
705
+ return RuleGroup(logic=LogicType.NOT, conditions=[self._to_rule_node(items[1], cfg)])
706
+
707
+ # A AND B AND C
708
+ # 假设同级逻辑相同 (pyparsing infix_notation特性)
709
+ if len(items) >= 3:
710
+ logic_token = items[1]
711
+ logic_enum = cfg.logic.get_enum(logic_token)
712
+
713
+ if not logic_enum:
714
+ raise ValueError(f"Unknown logic operator: {logic_token}")
715
+
716
+ conditions = [self._to_rule_node(items[i], cfg) for i in range(0, len(items), 2)]
717
+
718
+ return RuleGroup(logic=logic_enum, conditions=conditions)
719
+
720
+ # Fallback for single item (defensive)
721
+ if len(items) == 1:
722
+ return self._to_rule_node(items[0], cfg)
723
+
724
+ raise ValueError(f"Unexpected parse item: {parsed_item}")
725
+
726
+ def _to_actions(self, parsed_res: Any, cfg: LangConfig) -> Actions:
727
+ """
728
+ 将动作解析结果转换为 Actions 对象。
729
+
730
+ Args:
731
+ parsed_res: pyparsing 解析结果。
732
+ cfg: 当前语言配置。
733
+
734
+ Returns:
735
+ Actions: 转换后的动作对象。
736
+
737
+ Raises:
738
+ ValueError: 如果动作类型未知或存在冲突配置。
739
+ """
740
+ actions = Actions()
741
+ for item in parsed_res:
742
+ raw_type = item.type
743
+ raw_params = item.params.as_dict()
744
+
745
+ type_enum = cfg.actions.get_enum(raw_type)
746
+ if not type_enum:
747
+ raise ValueError(f"Unknown action type: {raw_type}")
748
+
749
+ if type_enum == ActionType.DELETE:
750
+ actions.delete.enabled = True
751
+
752
+ elif type_enum == ActionType.BAN:
753
+ if actions.ban.enabled:
754
+ raise ValueError("Multiple ban actions detected")
755
+ actions.ban.enabled = True
756
+ actions.ban.days = int(raw_params.get("days", 1))
757
+
758
+ elif type_enum == ActionType.NOTIFY:
759
+ if actions.notify.enabled:
760
+ raise ValueError("Multiple notify actions detected")
761
+ actions.notify.enabled = True
762
+ actions.notify.template = raw_params.pop("template", None)
763
+ actions.notify.params = raw_params
764
+
765
+ return actions
766
+
767
+ def parse_rule(self, text: str, mode: Literal["dsl", "cnl"] = "dsl") -> RuleNode:
768
+ """
769
+ 解析规则触发器字符串。
770
+
771
+ 将输入的字符串 (DSL 或 CNL) 解析为 RuleNode 对象 (Condition 或 RuleGroup)。
772
+
773
+ Args:
774
+ text: 待解析的规则字符串。
775
+ mode: 解析模式,可选 "dsl" (默认) 或 "cnl"。
776
+
777
+ Returns:
778
+ RuleNode: 解析生成的规则节点对象。
779
+
780
+ Raises:
781
+ ValueError: 当语法无法匹配或发生解析错误时抛出。
782
+ """
783
+ parser, _ = self._parsers[mode]
784
+ cfg = DSL_CONFIG if mode == "dsl" else CNL_CONFIG
785
+ try:
786
+ # parse_string(parse_all=True) 确保完全匹配
787
+ res = parser.parse_string(text, parse_all=True)[0]
788
+ return self._to_rule_node(res, cfg)
789
+ except pp.ParseException as e:
790
+ # 增强错误提示:可视化指出错误位置
791
+ raise ValueError(f"Parsing failed at position {e.col}:\n{e.line}\n{' ' * (e.col - 1)}^\n{e}") from e
792
+
793
+ def parse_actions(self, text: str, mode: Literal["dsl", "cnl"] = "dsl") -> Actions:
794
+ """
795
+ 解析动作字符串。
796
+
797
+ 将输入的动作字符串解析为 Actions 对象。支持 "动作名(参数)" 的调用格式。
798
+
799
+ Args:
800
+ text: 待解析的动作字符串。
801
+ mode: 解析模式,可选 "dsl" (默认) 或 "cnl"。
802
+
803
+ Returns:
804
+ Actions: 解析生成的动作对象。
805
+
806
+ Raises:
807
+ ValueError: 当语法无法匹配或动作名未知时抛出。
808
+ """
809
+ _, parser = self._parsers[mode]
810
+ cfg = DSL_CONFIG if mode == "dsl" else CNL_CONFIG
811
+ try:
812
+ res = parser.parse_string(text, parse_all=True)
813
+ return self._to_actions(res, cfg)
814
+ except pp.ParseException as e:
815
+ raise ValueError(f"Action parsing failed: {e}") from e
816
+
817
+ def scan_rules(self, text: str, mode: Literal["dsl", "cnl"] = "cnl") -> Generator[RuleNode, None, None]:
818
+ """
819
+ 从文本中扫描并提取所有合法的规则片段。
820
+
821
+ 该方法主要用于从非结构化的自然语言文本中提取看似合法的规则,
822
+ 常用于处理用户在聊天中输入的混合文本。
823
+
824
+ Args:
825
+ text: 输入的任意文本。
826
+ mode: 扫描模式,默认为 "cnl"。
827
+
828
+ Yields:
829
+ Generator[RuleNode, None, None]: 逐个返回提取到的规则节点。
830
+ """
831
+ parser, _ = self._parsers[mode]
832
+ cfg = DSL_CONFIG if mode == "dsl" else CNL_CONFIG
833
+
834
+ # scan_string 返回生成器 (parsed_obj, start_index, end_index)
835
+ for match, _start, _end in parser.scan_string(text):
836
+ try:
837
+ yield self._to_rule_node(match[0], cfg)
838
+ except Exception:
839
+ continue
840
+
841
+ def validate(self, text: str, mode: Literal["dsl", "cnl"]) -> tuple[bool, str | None]:
842
+ """
843
+ 验证规则字符串是否符合规范。
844
+
845
+ Args:
846
+ text: 待验证的规则字符串。
847
+ mode: 验证模式 ("dsl" 或 "cnl")。
848
+
849
+ Returns:
850
+ tuple[bool, str | None]:
851
+ - (True, None): 验证通过。
852
+ - (False, error_msg): 验证失败及错误信息。
853
+ """
854
+ try:
855
+ self.parse_rule(text, mode)
856
+ return True, None
857
+ except ValueError as e:
858
+ return False, str(e)
859
+
860
+ def dump_rule(self, node: RuleNode, mode: Literal["dsl", "cnl"] = "dsl") -> str:
861
+ """
862
+ 将 RuleNode 序列化为规则字符串。
863
+
864
+ 支持将内部的规则对象逆向生成 DSL 或 CNL 字符串,确保生成的字符串可以被重新解析。
865
+
866
+ Args:
867
+ node: 待序列化的规则节点。
868
+ mode: 输出模式,可选 "dsl" (默认) 或 "cnl"。
869
+
870
+ Returns:
871
+ str: 生成的规则字符串。
872
+ """
873
+ cfg = DSL_CONFIG if mode == "dsl" else CNL_CONFIG
874
+
875
+ if isinstance(node, Condition):
876
+ # 将 str 值转回 Enum 以查找 Token
877
+ try:
878
+ f_enum = FieldType(node.field)
879
+ except ValueError:
880
+ f_enum = None
881
+
882
+ # 如果是预定义的字段,用主Token,否则原样返回
883
+ field_str = cfg.fields.get_primary_token(f_enum) if f_enum else node.field
884
+
885
+ # Op 必须是规范的
886
+ o_enum = OperatorType(node.operator)
887
+ op_str = cfg.operators.get_primary_token(o_enum)
888
+
889
+ val = node.value
890
+
891
+ if isinstance(val, datetime):
892
+ # 格式:2023-01-01 12:00:00
893
+ val_str = val.strftime("%Y-%m-%d %H:%M:%S")
894
+ # 如果是整天,去掉时间部分让看起来更干净
895
+ if val.hour == 0 and val.minute == 0 and val.second == 0:
896
+ val_str = val.strftime("%Y-%m-%d")
897
+ elif isinstance(val, str):
898
+ # CNL 模式下也可以根据喜好改用中文引号,这里默认使用双引号以保持 JSON 兼容性
899
+ val_str = f'"{val}"'
900
+ elif isinstance(val, bool):
901
+ # 布尔值转回对应语言
902
+ bool_enum = BooleanType.TRUE if val else BooleanType.FALSE
903
+ val_str = cfg.booleans.get_primary_token(bool_enum)
904
+ elif isinstance(val, list):
905
+
906
+ def fmt_item(x: Any) -> str:
907
+ if isinstance(x, datetime):
908
+ return x.strftime("%Y-%m-%d %H:%M:%S")
909
+ return f'"{x}"' if isinstance(x, str) else str(x)
910
+
911
+ # 递归处理列表内元素
912
+ items = [fmt_item(x) for x in val]
913
+ # 使用主要的括号符号
914
+ lb = cfg.punctuation.get_primary_token(PunctuationType.LBRACK)
915
+ rb = cfg.punctuation.get_primary_token(PunctuationType.RBRACK)
916
+ # 使用主要的逗号分隔符
917
+ sep = cfg.punctuation.get_primary_token(PunctuationType.COMMA) + " "
918
+ val_str = f"{lb}{sep.join(items)}{rb}"
919
+ else:
920
+ val_str = str(val)
921
+
922
+ return f"{field_str}{op_str}{val_str}"
923
+
924
+ if isinstance(node, RuleGroup):
925
+ # 获取主要的逻辑词
926
+ l_enum = LogicType(node.logic)
927
+ logic_str = cfg.logic.get_primary_token(l_enum)
928
+
929
+ # 递归
930
+ children = [self.dump_rule(c, mode) for c in node.conditions]
931
+
932
+ # 拼接
933
+ joined = f" {logic_str} ".join(children)
934
+
935
+ # 如果是 NOT,加括号
936
+ if l_enum == LogicType.NOT:
937
+ return f"{logic_str} ({children[0]})"
938
+
939
+ # 顶层是否加括号通常由调用方决定,这里简单处理:如果是复合组,加上括号
940
+ return f"({joined})"
941
+
942
+ raise ValueError(f"Unknown node type: {type(node)}")
943
+
944
+ def dump_actions(self, actions: Actions, mode: Literal["dsl", "cnl"] = "dsl") -> str:
945
+ """
946
+ 将 Actions 对象序列化为动作字符串。
947
+
948
+ Args:
949
+ actions: 动作对象。
950
+ mode: 输出模式,可选 "dsl" (默认) 或 "cnl"。
951
+
952
+ Returns:
953
+ str: 生成的动作字符串。
954
+ """
955
+ cfg = DSL_CONFIG if mode == "dsl" else CNL_CONFIG
956
+ parts = []
957
+
958
+ # 获取标点
959
+ lb = cfg.punctuation.get_primary_token(PunctuationType.LPAR)
960
+ rb = cfg.punctuation.get_primary_token(PunctuationType.RPAR)
961
+ comma = cfg.punctuation.get_primary_token(PunctuationType.COMMA) + " "
962
+ eq = cfg.punctuation.get_primary_token(PunctuationType.ASSIGN)
963
+
964
+ def make_call(act_type: ActionType, params: dict[str, Any]) -> str:
965
+ t_name = cfg.actions.get_primary_token(act_type)
966
+ params_parts = []
967
+ for k, v in params.items():
968
+ v_str = f'"{v}"' if isinstance(v, str) else str(v)
969
+ params_parts.append(f"{k}{eq}{v_str}")
970
+ p_str = comma.join(params_parts)
971
+ return f"{t_name}{lb}{p_str}{rb}"
972
+
973
+ if actions.delete:
974
+ parts.append(make_call(ActionType.DELETE, {}))
975
+
976
+ if actions.ban.enabled:
977
+ parts.append(make_call(ActionType.BAN, {"days": actions.ban.days}))
978
+
979
+ if actions.notify.enabled:
980
+ p = actions.notify.params.copy()
981
+ if actions.notify.template:
982
+ p["template"] = actions.notify.template
983
+ parts.append(make_call(ActionType.NOTIFY, p))
984
+
985
+ prefix = cfg.punctuation.get_primary_token(PunctuationType.ACTION_PREFIX)
986
+ colon = cfg.punctuation.get_primary_token(PunctuationType.COLON)
987
+ comma_sep = cfg.punctuation.get_primary_token(PunctuationType.COMMA) + " "
988
+
989
+ body = comma_sep.join(parts)
990
+ return f"{prefix}{colon} {body}"