@zhin.js/plugin-group-suite 1.1.6 → 1.1.8

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 (79) hide show
  1. package/README.md +55 -24
  2. package/commands/forget/index.js +1 -0
  3. package/commands/forget/index.ts +1 -0
  4. package/commands/keyword-add/[keyword]/index.js +10 -7
  5. package/commands/keyword-add/[keyword]/index.ts +11 -8
  6. package/commands/keyword-list/index.js +8 -5
  7. package/commands/keyword-list/index.ts +9 -6
  8. package/commands/keyword-remove/[keyword]/index.js +10 -6
  9. package/commands/keyword-remove/[keyword]/index.ts +11 -7
  10. package/commands/stats-card/index.js +23 -0
  11. package/commands/stats-card/index.ts +24 -0
  12. package/commands/teach/index.js +1 -0
  13. package/commands/teach/index.ts +1 -0
  14. package/commands/teach-regex/index.js +1 -0
  15. package/commands/teach-regex/index.ts +1 -0
  16. package/handlers/welcome/index.js +25 -0
  17. package/handlers/welcome/index.ts +24 -0
  18. package/lib/checkin-lib.d.ts +4 -24
  19. package/lib/checkin-lib.d.ts.map +1 -1
  20. package/lib/checkin-lib.js +3 -4
  21. package/lib/checkin-lib.js.map +1 -1
  22. package/lib/config.d.ts +4 -0
  23. package/lib/config.d.ts.map +1 -1
  24. package/lib/config.js +2 -0
  25. package/lib/config.js.map +1 -1
  26. package/lib/conversation-scope.d.ts +21 -0
  27. package/lib/conversation-scope.d.ts.map +1 -0
  28. package/lib/conversation-scope.js +33 -0
  29. package/lib/conversation-scope.js.map +1 -0
  30. package/lib/index.d.ts +1 -1
  31. package/lib/index.d.ts.map +1 -1
  32. package/lib/index.js +1 -1
  33. package/lib/index.js.map +1 -1
  34. package/lib/keyword-store.d.ts +4 -0
  35. package/lib/keyword-store.d.ts.map +1 -1
  36. package/lib/keyword-store.js +15 -0
  37. package/lib/keyword-store.js.map +1 -1
  38. package/lib/runtime-state.d.ts +5 -1
  39. package/lib/runtime-state.d.ts.map +1 -1
  40. package/lib/runtime-state.js.map +1 -1
  41. package/lib/shared-runtime.d.ts +3 -8
  42. package/lib/shared-runtime.d.ts.map +1 -1
  43. package/lib/shared-runtime.js +4 -10
  44. package/lib/shared-runtime.js.map +1 -1
  45. package/lib/stats-lib.d.ts +5 -26
  46. package/lib/stats-lib.d.ts.map +1 -1
  47. package/lib/stats-lib.js +29 -15
  48. package/lib/stats-lib.js.map +1 -1
  49. package/lib/teach-lib.d.ts +2 -9
  50. package/lib/teach-lib.d.ts.map +1 -1
  51. package/lib/teach-lib.js +6 -8
  52. package/lib/teach-lib.js.map +1 -1
  53. package/middlewares/keyword-reply/index.js +3 -5
  54. package/middlewares/keyword-reply/index.ts +3 -5
  55. package/middlewares/stats-count/index.js +3 -7
  56. package/middlewares/stats-count/index.ts +3 -7
  57. package/package.json +29 -11
  58. package/plugin.js +9 -9
  59. package/schedules/flush-stats/index.js +11 -0
  60. package/schedules/flush-stats/index.ts +11 -0
  61. package/schema.json +51 -10
  62. package/skills/checkin/tools/checkin_query/index.js +5 -8
  63. package/skills/checkin/tools/checkin_query/index.ts +5 -9
  64. package/skills/checkin/tools/checkin_rank/index.js +4 -7
  65. package/skills/checkin/tools/checkin_rank/index.ts +4 -7
  66. package/skills/group-daily-analysis/tools/stats_query/index.js +3 -4
  67. package/skills/group-daily-analysis/tools/stats_query/index.ts +3 -4
  68. package/skills/group-daily-analysis/tools/stats_user/index.js +4 -5
  69. package/skills/group-daily-analysis/tools/stats_user/index.ts +4 -5
  70. package/src/checkin-lib.ts +7 -7
  71. package/src/config.ts +6 -0
  72. package/src/conversation-scope.ts +45 -0
  73. package/src/index.ts +1 -0
  74. package/src/keyword-store.ts +22 -0
  75. package/src/keywords.ts +72 -0
  76. package/src/runtime-state.ts +5 -1
  77. package/src/shared-runtime.ts +5 -14
  78. package/src/stats-lib.ts +36 -29
  79. package/src/teach-lib.ts +8 -17
@@ -0,0 +1,72 @@
1
+ import { createDeliveryTarget, databaseHostToken, type Token } from 'zhin.js';
2
+ import type { GroupSuiteConfig } from './config.js';
3
+ import type { ConversationInput } from './conversation-scope.js';
4
+ import { requireConversation } from './conversation-scope.js';
5
+ import { keywordStore } from './keyword-store.js';
6
+ import { resolveGroupSuiteRuntime } from './runtime-state.js';
7
+
8
+ export const KEYWORD_STORAGE_REQUIREMENT = '关键词管理需要 Database Host 的持久状态服务;请配置数据库,或显式设置 ephemeralKeywords: true 使用临时词库';
9
+ class KeywordStorageRequirementError extends Error {}
10
+ const MAX_KEYWORD_BYTES = 64 * 1024;
11
+
12
+ type KeywordRows = readonly (readonly [string, string])[];
13
+ interface KeywordContext {
14
+ readonly config: Readonly<Partial<GroupSuiteConfig>>;
15
+ use<T>(token: Token<T>): T;
16
+ }
17
+
18
+ /** Persistent owner + exact conversation state; volatile storage requires an explicit opt-in. */
19
+ export function keywords(input: ConversationInput | undefined, context: KeywordContext) {
20
+ const target = requireConversation(input ?? {});
21
+ if (target.kind !== 'group') throw new Error('Keywords require a group conversation');
22
+ if (context.config.ephemeralKeywords === true) {
23
+ const store = keywordStore({ conversation: target }, resolveGroupSuiteRuntime(context));
24
+ return {
25
+ read: async () => [...store.entries()] as KeywordRows,
26
+ update: async (updater: (current: Map<string, string>) => void) => {
27
+ const next = new Map(store);
28
+ updater(next);
29
+ assertLimit(next);
30
+ store.clear();
31
+ for (const [key, value] of next) store.set(key, value);
32
+ },
33
+ };
34
+ }
35
+ const hostState = (() => {
36
+ try { return context.use(databaseHostToken).state; }
37
+ catch (cause) { throw new KeywordStorageRequirementError(KEYWORD_STORAGE_REQUIREMENT, { cause }); }
38
+ })();
39
+ const state = hostState.forConversation(createDeliveryTarget({ conversation: target }));
40
+ return {
41
+ read: async () => (await state.get<KeywordRows>('keywords')) ?? [],
42
+ update: async (updater: (current: Map<string, string>) => void) => {
43
+ await state.update<KeywordRows>('keywords', current => {
44
+ const next = new Map(current ?? []);
45
+ updater(next);
46
+ assertLimit(next);
47
+ return [...next.entries()];
48
+ });
49
+ },
50
+ };
51
+ }
52
+
53
+ /** Management commands surface a storage requirement instead of inventing an empty dictionary. */
54
+ export function keywordsForCommand(input: ConversationInput | undefined, context: KeywordContext) {
55
+ try { return keywords(input, context); }
56
+ catch (error) {
57
+ if (error instanceof KeywordStorageRequirementError) return error.message;
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ function assertLimit(store: Map<string, string>): void {
63
+ if (store.size > 200) throw new Error('Group keywords are limited to 200 entries');
64
+ if (new TextEncoder().encode(JSON.stringify([...store.entries()])).byteLength >= MAX_KEYWORD_BYTES) {
65
+ throw new Error('Keyword dictionary JSON must be below 64 KiB (UTF-8); shorten replies or remove entries');
66
+ }
67
+ for (const [keyword, reply] of store) {
68
+ if (!keyword || keyword.length > 200 || !reply || reply.length > 2000) {
69
+ throw new Error('Keyword must contain 1–200 characters and reply 1–2000 characters');
70
+ }
71
+ }
72
+ }
@@ -7,14 +7,18 @@ export interface PendingStatsIncrement {
7
7
  group_id: string;
8
8
  date: string;
9
9
  count: number;
10
+ recorded: number;
11
+ flushed: number;
10
12
  }
11
13
 
12
14
  /** All mutable state owned by one group-suite plugin instance. */
13
15
  export interface GroupSuiteRuntime {
14
16
  readonly db: GroupSuiteMemoryDb;
15
- readonly keywords: Map<string, string>;
17
+ readonly keywords: Map<string, Map<string, string>>;
16
18
  readonly teachCooldowns: Map<string, number>;
17
19
  readonly statsBuffer: Map<string, PendingStatsIncrement>;
20
+ /** Serialized finite batches shared by admitted queries and scheduler calls. */
21
+ statsFlush?: Promise<void>;
18
22
  readonly checkinChains: Map<string, Promise<unknown>>;
19
23
  }
20
24
 
@@ -1,3 +1,4 @@
1
+ import { conversationKey, requireConversation, type ConversationInput } from './conversation-scope.js';
1
2
  export function todayStr(): string {
2
3
  const d = new Date();
3
4
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
@@ -17,20 +18,10 @@ export function randomInt(min: number, max: number): number {
17
18
  return Math.floor(Math.random() * (max - min + 1)) + min;
18
19
  }
19
20
 
20
- /** Runtime Message → group/global context */
21
- export function resolveContextKey(input: {
22
- conversation?: { readonly kind?: string; readonly id?: string };
23
- metadata?: Readonly<Record<string, unknown>>;
24
- }): { type: string; id: string } {
25
- const meta = input.metadata ?? {};
26
- const channelType = String(meta.type ?? meta.channelType ?? '');
27
- if (channelType === 'group' || channelType === 'guild' || channelType === 'channel') {
28
- return { type: 'group', id: String(meta.channelId ?? input.conversation?.id ?? '') };
29
- }
30
- if (input.conversation && channelType !== 'private') {
31
- return { type: 'group', id: String(input.conversation.id ?? '') };
32
- }
33
- return { type: 'global', id: '' };
21
+ /** Every scene includes endpoint, parent and thread; no cross-bot global fallback. */
22
+ export function resolveContextKey(input: ConversationInput): { type: string; id: string } {
23
+ const target = requireConversation(input);
24
+ return { type: target.kind, id: conversationKey(target) };
34
25
  }
35
26
 
36
27
  export function resolveSender(input: {
package/src/stats-lib.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { conversationKey, requireConversation, type ConversationInput } from './conversation-scope.js';
1
2
  import type { GroupSuiteConfig } from './config.js';
2
3
  import { getStatsModel } from './db-store.js';
3
4
  import {
@@ -26,29 +27,14 @@ export function monthStartStr(): string {
26
27
  }
27
28
 
28
29
  function bufferKey(userId: string, groupId: string, date: string): string {
29
- return `${userId}:${groupId}:${date}`;
30
+ return JSON.stringify([userId, groupId, date]);
30
31
  }
31
32
 
32
- export function resolveGroupId(input: {
33
- conversation?: { readonly kind?: string; readonly id?: string };
34
- metadata?: Readonly<Record<string, unknown>>;
35
- }): string {
36
- const meta = input.metadata ?? {};
37
- const channelType = String(meta.type ?? meta.channelType ?? '');
38
- if (channelType === 'group' || channelType === 'guild' || channelType === 'channel') {
39
- return String(meta.channelId ?? input.conversation?.id ?? '');
40
- }
41
- if (input.conversation && channelType !== 'private') {
42
- return String(input.conversation.id ?? '');
43
- }
44
- return '';
33
+ export function resolveGroupId(input: ConversationInput): string {
34
+ return conversationKey(requireConversation(input));
45
35
  }
46
36
 
47
- export function recordMessage(input: {
48
- sender?: { readonly id: string; readonly name?: string };
49
- conversation?: { readonly kind?: string; readonly id?: string };
50
- metadata?: Readonly<Record<string, unknown>>;
51
- }, runtime: GroupSuiteRuntime): void {
37
+ export function recordMessage(input: ConversationInput, runtime: GroupSuiteRuntime): void {
52
38
  const targetBuffer = runtime.statsBuffer;
53
39
  const { id: userId, name: userName } = resolveSender(input);
54
40
  if (!userId) return;
@@ -57,6 +43,7 @@ export function recordMessage(input: {
57
43
  const pending = targetBuffer.get(key);
58
44
  if (pending) {
59
45
  pending.count++;
46
+ pending.recorded++;
60
47
  pending.user_name = userName;
61
48
  } else {
62
49
  targetBuffer.set(key, {
@@ -65,17 +52,40 @@ export function recordMessage(input: {
65
52
  group_id: groupId,
66
53
  date: todayStr(),
67
54
  count: 1,
55
+ recorded: 1,
56
+ flushed: 0,
68
57
  });
69
58
  }
70
59
  }
71
60
 
72
- export async function flushStatsBuffer(runtime: GroupSuiteRuntime): Promise<void> {
61
+ interface StatsBatchEntry {
62
+ readonly key: string;
63
+ readonly entry: PendingStatsIncrement;
64
+ readonly watermark: number;
65
+ }
66
+
67
+ /** Flush only the increments visible to this caller; new traffic cannot prolong its batch. */
68
+ export function flushStatsBuffer(runtime: GroupSuiteRuntime): Promise<void> {
69
+ const batch = [...runtime.statsBuffer.entries()].map(([key, entry]) => ({
70
+ key, entry, watermark: entry.recorded,
71
+ }));
72
+ const previous = runtime.statsFlush ?? Promise.resolve();
73
+ const flush = previous.catch(() => undefined).then(() => flushStatsBatch(runtime, batch)).finally(() => {
74
+ if (runtime.statsFlush === flush) runtime.statsFlush = undefined;
75
+ });
76
+ runtime.statsFlush = flush;
77
+ return flush;
78
+ }
79
+
80
+ async function flushStatsBatch(runtime: GroupSuiteRuntime, batch: readonly StatsBatchEntry[]): Promise<void> {
73
81
  const targetBuffer = runtime.statsBuffer;
82
+ if (batch.length === 0) return;
74
83
  const M = getStatsModel(runtime.db);
75
- if (!M || targetBuffer.size === 0) return;
76
- for (const [key, entry] of [...targetBuffer.entries()]) {
77
- // 快照本次要落库的计数;flush 期间新累计的部分留在缓冲
78
- const countSnapshot = entry.count;
84
+ if (!M) return;
85
+ for (const { key, entry, watermark } of batch) {
86
+ // Earlier batches may already have committed part/all of this caller's watermark.
87
+ const countSnapshot = Math.min(entry.count, watermark - entry.flushed);
88
+ if (countSnapshot <= 0 || targetBuffer.get(key) !== entry) continue;
79
89
  try {
80
90
  const existing = (await M.select().where({
81
91
  user_id: entry.user_id,
@@ -104,6 +114,7 @@ export async function flushStatsBuffer(runtime: GroupSuiteRuntime): Promise<void
104
114
  }
105
115
  // 只移除写入成功的部分;失败的 key 留在缓冲等下一轮重试
106
116
  entry.count -= countSnapshot;
117
+ entry.flushed += countSnapshot;
107
118
  if (entry.count <= 0 && targetBuffer.get(key) === entry) targetBuffer.delete(key);
108
119
  } catch {
109
120
  /* single row failure: keep entry in buffer for next flush */
@@ -139,11 +150,7 @@ export async function queryStats(
139
150
  return result;
140
151
  }
141
152
 
142
- type MessageInput = {
143
- sender?: { readonly id: string; readonly name?: string };
144
- target?: string;
145
- metadata?: Readonly<Record<string, unknown>>;
146
- };
153
+ type MessageInput = ConversationInput;
147
154
 
148
155
  export async function statsRankText(
149
156
  input: MessageInput,
package/src/teach-lib.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ConversationInput } from './conversation-scope.js';
1
2
  import type { GroupSuiteConfig } from './config.js';
2
3
  import { getTeachModel } from './db-store.js';
3
4
  import {
@@ -70,12 +71,7 @@ function qaWhereKey(row: {
70
71
  };
71
72
  }
72
73
 
73
- type MessageInput = {
74
- sender?: { readonly id: string; readonly name?: string };
75
- target?: string;
76
- content?: unknown;
77
- metadata?: Readonly<Record<string, unknown>>;
78
- };
74
+ type MessageInput = ConversationInput;
79
75
 
80
76
  async function findMatch(
81
77
  content: string,
@@ -86,12 +82,9 @@ async function findMatch(
86
82
  ): Promise<Record<string, unknown> | null> {
87
83
  const QA = getTeachModel(runtime.db);
88
84
  if (!QA) return null;
89
- const allItems = (await QA.select()) as Record<string, unknown>[];
90
- const candidates = allItems.filter(
91
- (item) =>
92
- item.context_type === 'global' ||
93
- (item.context_type === ctxType && item.context_id === ctxId),
94
- );
85
+ const candidates = (await QA.select().where({
86
+ context_type: ctxType, context_id: ctxId,
87
+ })) as Record<string, unknown>[];
95
88
  for (const item of candidates) {
96
89
  if (!item.is_regex && item.question === content) return item;
97
90
  }
@@ -196,9 +189,7 @@ export async function teachList(
196
189
  const safePage = Math.max(1, page);
197
190
  const { type: ctxType, id: ctxId } = resolveContextKey(input);
198
191
  const allItems = (await QA.select().where(
199
- ctxType === 'global'
200
- ? { context_type: 'global' }
201
- : { context_type: ctxType, context_id: ctxId },
192
+ { context_type: ctxType, context_id: ctxId },
202
193
  )) as Record<string, unknown>[];
203
194
  if (allItems.length === 0) return '还没有教过任何问答哦~';
204
195
  const totalPages = Math.ceil(allItems.length / cfg.teachPageSize);
@@ -210,7 +201,7 @@ export async function teachList(
210
201
  const q = item.is_regex ? `/${item.question}/` : item.question;
211
202
  return `${start + i + 1}. ${prefix} ${q} → ${item.answer}`;
212
203
  });
213
- const header = ctxType === 'global' ? '全局问答列表' : '本群问答列表';
204
+ const header = '本会话问答列表';
214
205
  return `${header}\n${lines.join('\n')}\n第 ${pageNum}/${totalPages} 页 · 共 ${allItems.length} 条`;
215
206
  }
216
207
 
@@ -253,7 +244,7 @@ export async function tryTeachReply(
253
244
  const matched = await findMatch(content, ctxType, ctxId, cfg, runtime);
254
245
  if (!matched) return null;
255
246
 
256
- const cooldownKey = `${matched.question}:${ctxType}:${ctxId}:${matched.is_regex ?? 0}`;
247
+ const cooldownKey = JSON.stringify([matched.question, ctxType, ctxId, matched.is_regex ?? 0]);
257
248
  const cooldowns = runtime.teachCooldowns;
258
249
  const last = cooldowns.get(cooldownKey);
259
250
  if (last && Date.now() - last < cfg.teachCooldownMs) return null;