@zhin.js/plugin-group-suite 1.1.7 → 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.
- package/README.md +55 -24
- package/commands/forget/index.js +1 -0
- package/commands/forget/index.ts +1 -0
- package/commands/keyword-add/[keyword]/index.js +10 -7
- package/commands/keyword-add/[keyword]/index.ts +11 -8
- package/commands/keyword-list/index.js +8 -5
- package/commands/keyword-list/index.ts +9 -6
- package/commands/keyword-remove/[keyword]/index.js +10 -6
- package/commands/keyword-remove/[keyword]/index.ts +11 -7
- package/commands/stats-card/index.js +23 -0
- package/commands/stats-card/index.ts +24 -0
- package/commands/teach/index.js +1 -0
- package/commands/teach/index.ts +1 -0
- package/commands/teach-regex/index.js +1 -0
- package/commands/teach-regex/index.ts +1 -0
- package/handlers/welcome/index.js +25 -0
- package/handlers/welcome/index.ts +24 -0
- package/lib/checkin-lib.d.ts +4 -24
- package/lib/checkin-lib.d.ts.map +1 -1
- package/lib/checkin-lib.js +3 -4
- package/lib/checkin-lib.js.map +1 -1
- package/lib/config.d.ts +4 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +2 -0
- package/lib/config.js.map +1 -1
- package/lib/conversation-scope.d.ts +21 -0
- package/lib/conversation-scope.d.ts.map +1 -0
- package/lib/conversation-scope.js +33 -0
- package/lib/conversation-scope.js.map +1 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/keyword-store.d.ts +4 -0
- package/lib/keyword-store.d.ts.map +1 -1
- package/lib/keyword-store.js +15 -0
- package/lib/keyword-store.js.map +1 -1
- package/lib/runtime-state.d.ts +5 -1
- package/lib/runtime-state.d.ts.map +1 -1
- package/lib/runtime-state.js.map +1 -1
- package/lib/shared-runtime.d.ts +3 -8
- package/lib/shared-runtime.d.ts.map +1 -1
- package/lib/shared-runtime.js +4 -10
- package/lib/shared-runtime.js.map +1 -1
- package/lib/stats-lib.d.ts +5 -26
- package/lib/stats-lib.d.ts.map +1 -1
- package/lib/stats-lib.js +29 -15
- package/lib/stats-lib.js.map +1 -1
- package/lib/teach-lib.d.ts +2 -9
- package/lib/teach-lib.d.ts.map +1 -1
- package/lib/teach-lib.js +6 -8
- package/lib/teach-lib.js.map +1 -1
- package/middlewares/keyword-reply/index.js +3 -5
- package/middlewares/keyword-reply/index.ts +3 -5
- package/middlewares/stats-count/index.js +3 -7
- package/middlewares/stats-count/index.ts +3 -7
- package/package.json +27 -9
- package/plugin.js +9 -9
- package/schedules/flush-stats/index.js +11 -0
- package/schedules/flush-stats/index.ts +11 -0
- package/schema.json +51 -10
- package/skills/checkin/tools/checkin_query/index.js +5 -8
- package/skills/checkin/tools/checkin_query/index.ts +5 -9
- package/skills/checkin/tools/checkin_rank/index.js +4 -7
- package/skills/checkin/tools/checkin_rank/index.ts +4 -7
- package/skills/group-daily-analysis/tools/stats_query/index.js +3 -4
- package/skills/group-daily-analysis/tools/stats_query/index.ts +3 -4
- package/skills/group-daily-analysis/tools/stats_user/index.js +4 -5
- package/skills/group-daily-analysis/tools/stats_user/index.ts +4 -5
- package/src/checkin-lib.ts +7 -7
- package/src/config.ts +6 -0
- package/src/conversation-scope.ts +45 -0
- package/src/index.ts +1 -0
- package/src/keyword-store.ts +22 -0
- package/src/keywords.ts +72 -0
- package/src/runtime-state.ts +5 -1
- package/src/shared-runtime.ts +5 -14
- package/src/stats-lib.ts +36 -29
- package/src/teach-lib.ts +8 -17
package/src/keywords.ts
ADDED
|
@@ -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
|
+
}
|
package/src/runtime-state.ts
CHANGED
|
@@ -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
|
|
package/src/shared-runtime.ts
CHANGED
|
@@ -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
|
-
/**
|
|
21
|
-
export function resolveContextKey(input: {
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
30
|
+
return JSON.stringify([userId, groupId, date]);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export function resolveGroupId(input: {
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
76
|
-
for (const
|
|
77
|
-
//
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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;
|