@zhin.js/plugin-group-suite 1.1.25 → 1.1.26

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -1
  3. package/commands/checkin/index.js +3 -3
  4. package/commands/checkin/index.ts +3 -3
  5. package/commands/forget/index.js +1 -1
  6. package/commands/forget/index.ts +1 -1
  7. package/commands/keyword-add/[keyword]/index.js +1 -1
  8. package/commands/keyword-add/[keyword]/index.ts +1 -1
  9. package/commands/keyword-remove/[keyword]/index.js +1 -1
  10. package/commands/keyword-remove/[keyword]/index.ts +1 -1
  11. package/commands/rank/index.js +3 -3
  12. package/commands/rank/index.ts +3 -3
  13. package/commands/stats/index.js +3 -3
  14. package/commands/stats/index.ts +3 -3
  15. package/commands/stats-card/index.js +2 -2
  16. package/commands/stats-card/index.ts +2 -2
  17. package/commands/stats-rank/index.js +3 -3
  18. package/commands/stats-rank/index.ts +3 -3
  19. package/commands/stats-week/index.js +3 -3
  20. package/commands/stats-week/index.ts +3 -3
  21. package/commands/teach/index.js +4 -4
  22. package/commands/teach/index.ts +4 -4
  23. package/commands/teach-list/[[page]]/index.js +3 -3
  24. package/commands/teach-list/[[page]]/index.ts +3 -3
  25. package/commands/teach-regex/index.js +4 -4
  26. package/commands/teach-regex/index.ts +4 -4
  27. package/handlers/welcome/index.js +2 -2
  28. package/handlers/welcome/index.ts +2 -2
  29. package/lib/config.d.ts +15 -1
  30. package/lib/config.d.ts.map +1 -1
  31. package/lib/config.js +11 -1
  32. package/lib/config.js.map +1 -1
  33. package/lib/teach-lib.d.ts +12 -0
  34. package/lib/teach-lib.d.ts.map +1 -1
  35. package/lib/teach-lib.js +104 -29
  36. package/lib/teach-lib.js.map +1 -1
  37. package/middlewares/keyword-reply/index.js +2 -2
  38. package/middlewares/keyword-reply/index.ts +2 -2
  39. package/middlewares/teach-reply/index.js +2 -2
  40. package/middlewares/teach-reply/index.ts +2 -2
  41. package/package.json +16 -21
  42. package/plugin.js +5 -1
  43. package/schema.json +97 -14
  44. package/src/config.ts +22 -2
  45. package/src/teach-lib.ts +86 -23
package/src/teach-lib.ts CHANGED
@@ -11,15 +11,82 @@ import type { GroupSuiteRuntime } from './runtime-state.js';
11
11
 
12
12
  export const TEACH_USAGE_HINT = '格式:关键词 回答,或 问题|答案';
13
13
 
14
- function isSafeRegex(pattern: string): boolean {
14
+ /** Regex rules are only tried against messages up to this length. */
15
+ export const TEACH_REGEX_MAX_CONTENT = 200;
16
+ /** `*`, `+` and `{n,m}` quantifiers allowed in one pattern (bounds polynomial backtracking). */
17
+ const TEACH_REGEX_MAX_QUANTIFIERS = 3;
18
+
19
+ /**
20
+ * Conservative structural check for user-taught patterns, which run against every inbound
21
+ * message. It refuses the shapes that backtrack super-linearly:
22
+ * - a repeated group whose body contains a quantifier or an alternation
23
+ * (`(\w+\s?)+`, `(a|aa)+`, `(.*a){10}`);
24
+ * - backreferences and lookarounds;
25
+ * - more than a few repeat operators in one pattern (`.*.*.*.*x`).
26
+ * A character class (`[ab]+`) is the accepted way to repeat a set of alternatives.
27
+ */
28
+ export function isSafeRegex(pattern: string): boolean {
15
29
  if (pattern.length > 200) return false;
16
- if (/(\+|\*|\{)\)?(\+|\*|\{)/.test(pattern)) return false;
17
- if (/\([^)]*(\+|\*)[^)]*(\+|\*)[^)]*\)[+*]/.test(pattern)) return false;
18
- const altGroups = pattern.match(/\([^)]*\|[^)]*\)/g) || [];
19
- for (const g of altGroups) {
20
- if ((g.match(/\|/g) || []).length > 10) return false;
30
+ /** One entry per open group: true once its body holds a quantifier or an alternation. */
31
+ const open: boolean[] = [];
32
+ /** Set right after `)`: whether the group that just closed is "complex". */
33
+ let closed: boolean | undefined;
34
+ let repeats = 0;
35
+ for (let i = 0; i < pattern.length; i += 1) {
36
+ const ch = pattern[i]!;
37
+ const group = closed;
38
+ closed = undefined;
39
+ if (ch === '\\') {
40
+ const next = pattern[i + 1] ?? '';
41
+ if (/[1-9k]/.test(next)) return false;
42
+ i += 1;
43
+ } else if (ch === '[') {
44
+ // Skip the class body; quantifier characters inside it are literals.
45
+ for (i += 1; i < pattern.length && pattern[i] !== ']'; i += 1) if (pattern[i] === '\\') i += 1;
46
+ } else if (ch === '(') {
47
+ if (/^\(\?<?[=!]/.test(pattern.slice(i, i + 4))) return false;
48
+ open.push(false);
49
+ } else if (ch === ')') {
50
+ const complex = open.pop();
51
+ if (complex === undefined) return false;
52
+ // A complex group makes its parent complex too: `((a+))+`.
53
+ if (complex && open.length > 0) open[open.length - 1] = true;
54
+ closed = complex;
55
+ } else if (ch === '|') {
56
+ if (open.length > 0) open[open.length - 1] = true;
57
+ } else if (ch === '*' || ch === '+' || ch === '?' || (ch === '{' && /^\{\d+(?:,\d*)?\}/.test(pattern.slice(i)))) {
58
+ if (ch !== '?') {
59
+ if (group) return false;
60
+ repeats += 1;
61
+ if (repeats > TEACH_REGEX_MAX_QUANTIFIERS) return false;
62
+ }
63
+ if (open.length > 0) open[open.length - 1] = true;
64
+ if (ch === '{') i = pattern.indexOf('}', i);
65
+ // A following `?` only makes the quantifier lazy.
66
+ if (pattern[i + 1] === '?') i += 1;
67
+ }
68
+ }
69
+ return open.length === 0;
70
+ }
71
+
72
+ /** Compiled teach patterns, keyed by source. `null` marks a pattern that must not run. */
73
+ const compiledTeachRegex = new Map<string, RegExp | null>();
74
+
75
+ function teachRegex(source: string): RegExp | null {
76
+ const cached = compiledTeachRegex.get(source);
77
+ if (cached !== undefined) return cached;
78
+ let compiled: RegExp | null = null;
79
+ // Rules stored before the stricter check are re-validated here, not only when they are taught.
80
+ if (isSafeRegex(source)) {
81
+ try {
82
+ compiled = new RegExp(source, 'i');
83
+ } catch {
84
+ compiled = null;
85
+ }
21
86
  }
22
- return true;
87
+ if (compiledTeachRegex.size >= 1000) compiledTeachRegex.clear();
88
+ compiledTeachRegex.set(source, compiled);
89
+ return compiled;
23
90
  }
24
91
 
25
92
  export function parseTeachPair(raw: string): { question: string; answer: string } | null {
@@ -83,20 +150,20 @@ async function findMatch(
83
150
  ): Promise<Record<string, unknown> | null> {
84
151
  const QA = getTeachModel(runtime.db);
85
152
  if (!QA) return null;
86
- const candidates = (await QA.select().where({
87
- context_type: ctxType, context_id: ctxId,
153
+ // Exact rules are a point lookup; the full rule list is only loaded when regex rules can apply.
154
+ const exact = (await QA.select().where({
155
+ question: content, context_type: ctxType, context_id: ctxId,
88
156
  })) as Record<string, unknown>[];
89
- for (const item of candidates) {
90
- if (!item.is_regex && item.question === content) return item;
157
+ for (const item of exact) {
158
+ if (!item.is_regex) return item;
91
159
  }
92
- if (cfg.teachAllowRegex) {
160
+ if (cfg.teachAllowRegex && content.length <= TEACH_REGEX_MAX_CONTENT) {
161
+ const candidates = (await QA.select().where({
162
+ context_type: ctxType, context_id: ctxId,
163
+ })) as Record<string, unknown>[];
93
164
  for (const item of candidates) {
94
165
  if (!item.is_regex) continue;
95
- try {
96
- if (new RegExp(String(item.question), 'i').test(content)) return item;
97
- } catch {
98
- continue;
99
- }
166
+ if (teachRegex(String(item.question))?.test(content)) return item;
100
167
  }
101
168
  }
102
169
  return null;
@@ -273,12 +340,8 @@ export async function tryTeachReply(
273
340
  const sender = resolveSender(input);
274
341
  let answer = String(matched.answer ?? '');
275
342
  if (matched.is_regex) {
276
- try {
277
- const m = content.match(new RegExp(String(matched.question), 'i'));
278
- answer = processAnswer(answer, sender, m || undefined, input.locale);
279
- } catch {
280
- /* ignore */
281
- }
343
+ const m = teachRegex(String(matched.question))?.exec(content);
344
+ answer = processAnswer(answer, sender, m || undefined, input.locale);
282
345
  } else {
283
346
  answer = processAnswer(answer, sender, undefined, input.locale);
284
347
  }