@zhin.js/plugin-group-suite 0.1.0

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 (58) hide show
  1. package/README.md +69 -0
  2. package/lib/admin.d.ts +4 -0
  3. package/lib/admin.d.ts.map +1 -0
  4. package/lib/admin.js +123 -0
  5. package/lib/admin.js.map +1 -0
  6. package/lib/analysis.d.ts +83 -0
  7. package/lib/analysis.d.ts.map +1 -0
  8. package/lib/analysis.js +167 -0
  9. package/lib/analysis.js.map +1 -0
  10. package/lib/checkin.d.ts +4 -0
  11. package/lib/checkin.d.ts.map +1 -0
  12. package/lib/checkin.js +163 -0
  13. package/lib/checkin.js.map +1 -0
  14. package/lib/config.d.ts +71 -0
  15. package/lib/config.d.ts.map +1 -0
  16. package/lib/config.js +44 -0
  17. package/lib/config.js.map +1 -0
  18. package/lib/daily-analysis.d.ts +7 -0
  19. package/lib/daily-analysis.d.ts.map +1 -0
  20. package/lib/daily-analysis.js +346 -0
  21. package/lib/daily-analysis.js.map +1 -0
  22. package/lib/index.d.ts +2 -0
  23. package/lib/index.d.ts.map +1 -0
  24. package/lib/index.js +30 -0
  25. package/lib/index.js.map +1 -0
  26. package/lib/shared.d.ts +23 -0
  27. package/lib/shared.d.ts.map +1 -0
  28. package/lib/shared.js +30 -0
  29. package/lib/shared.js.map +1 -0
  30. package/lib/stats.d.ts +6 -0
  31. package/lib/stats.d.ts.map +1 -0
  32. package/lib/stats.js +244 -0
  33. package/lib/stats.js.map +1 -0
  34. package/lib/teach.d.ts +4 -0
  35. package/lib/teach.d.ts.map +1 -0
  36. package/lib/teach.js +252 -0
  37. package/lib/teach.js.map +1 -0
  38. package/package.json +69 -0
  39. package/plugin.yml +3 -0
  40. package/skills/checkin/SKILL.md +49 -0
  41. package/skills/group-daily-analysis/SKILL.md +29 -0
  42. package/src/admin.ts +145 -0
  43. package/src/analysis.ts +227 -0
  44. package/src/checkin.ts +191 -0
  45. package/src/config.ts +72 -0
  46. package/src/daily-analysis.ts +467 -0
  47. package/src/index.ts +33 -0
  48. package/src/shared.ts +42 -0
  49. package/src/stats.ts +288 -0
  50. package/src/teach.ts +259 -0
  51. package/tools/checkin-query/checkin-query.tool.md +11 -0
  52. package/tools/checkin-query/handler.ts +21 -0
  53. package/tools/checkin-rank/checkin-rank.tool.md +11 -0
  54. package/tools/checkin-rank/handler.ts +16 -0
  55. package/tools/stats-query/handler.ts +25 -0
  56. package/tools/stats-query/stats-query.tool.md +16 -0
  57. package/tools/stats-user/handler.ts +27 -0
  58. package/tools/stats-user/stats-user.tool.md +12 -0
package/src/stats.ts ADDED
@@ -0,0 +1,288 @@
1
+ import { formatCompact, MessageCommand, type Plugin } from "zhin.js";
2
+ import type { GroupSuiteConfig } from "./config.js";
3
+ import { getMessageContextKey, todayStr, ts } from "./shared.js";
4
+
5
+ export function weekStartStr(): string {
6
+ const d = new Date();
7
+ const day = d.getDay() || 7;
8
+ d.setDate(d.getDate() - day + 1);
9
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
10
+ }
11
+
12
+ export function monthStartStr(): string {
13
+ const d = new Date();
14
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`;
15
+ }
16
+
17
+ let _db: any = null;
18
+
19
+ function getModel(): any {
20
+ return _db?.models?.get("message_stats") ?? null;
21
+ }
22
+
23
+ interface PendingIncrement {
24
+ user_id: string;
25
+ user_name: string;
26
+ group_id: string;
27
+ date: string;
28
+ count: number;
29
+ }
30
+
31
+ const buffer = new Map<string, PendingIncrement>();
32
+
33
+ function bufferKey(userId: string, groupId: string, date: string): string {
34
+ return `${userId}:${groupId}:${date}`;
35
+ }
36
+
37
+ function getGroupId(message: { $channel?: { type?: string; id?: string } }): string {
38
+ if (message.$channel?.type === "group") {
39
+ return String(message.$channel.id ?? "");
40
+ }
41
+ return "";
42
+ }
43
+
44
+ async function flushBuffer(): Promise<void> {
45
+ const M = getModel();
46
+ if (!M || buffer.size === 0) return;
47
+ const entries = [...buffer.values()];
48
+ buffer.clear();
49
+ for (const entry of entries) {
50
+ try {
51
+ const existing: any[] = await M.select().where({
52
+ user_id: entry.user_id,
53
+ group_id: entry.group_id,
54
+ date: entry.date,
55
+ });
56
+ if (existing.length > 0) {
57
+ await M.update({
58
+ count: (existing[0].count || 0) + entry.count,
59
+ user_name: entry.user_name,
60
+ updated_at: ts(),
61
+ }).where({ id: existing[0].id });
62
+ } else {
63
+ await M.insert({
64
+ user_id: entry.user_id,
65
+ user_name: entry.user_name,
66
+ group_id: entry.group_id,
67
+ date: entry.date,
68
+ count: entry.count,
69
+ updated_at: ts(),
70
+ });
71
+ }
72
+ } catch {
73
+ /* single row failure */
74
+ }
75
+ }
76
+ }
77
+
78
+ async function queryStats(
79
+ groupId: string,
80
+ fromDate: string,
81
+ ): Promise<Map<string, { name: string; count: number }>> {
82
+ const M = getModel();
83
+ if (!M) return new Map();
84
+ const all: any[] = groupId
85
+ ? await M.select().where({ group_id: groupId })
86
+ : await M.select();
87
+ const result = new Map<string, { name: string; count: number }>();
88
+ for (const row of all) {
89
+ if (row.date < fromDate) continue;
90
+ const existing = result.get(row.user_id);
91
+ if (existing) {
92
+ existing.count += row.count || 0;
93
+ if (row.user_name) existing.name = row.user_name;
94
+ } else {
95
+ result.set(row.user_id, {
96
+ name: row.user_name || row.user_id,
97
+ count: row.count || 0,
98
+ });
99
+ }
100
+ }
101
+ return result;
102
+ }
103
+
104
+ function formatRank(
105
+ stats: Map<string, { name: string; count: number }>,
106
+ title: string,
107
+ size: number,
108
+ myUserId?: string,
109
+ ): string {
110
+ const sorted = [...stats.entries()].sort((a, b) => b[1].count - a[1].count);
111
+ const total = sorted.reduce((s, [, v]) => s + v.count, 0);
112
+ const top = sorted.slice(0, size);
113
+ if (top.length === 0) return `${title}\n暂无数据`;
114
+ const medals = ["🥇", "🥈", "🥉"];
115
+ const lines = top.map(([, v], i) => {
116
+ const prefix = i < 3 ? medals[i] : `${i + 1}.`;
117
+ return `${prefix} ${v.name} — ${v.count}条`;
118
+ });
119
+ let footer = `\n共 ${stats.size} 人, ${total} 条消息`;
120
+ if (myUserId) {
121
+ const myRank = sorted.findIndex(([uid]) => uid === myUserId);
122
+ if (myRank >= 0) {
123
+ footer += ` | 你第${myRank + 1}名 (${sorted[myRank][1].count}条)`;
124
+ }
125
+ }
126
+ return `${title}\n${lines.join("\n")}${footer}`;
127
+ }
128
+
129
+ export function registerStats(plugin: Plugin, cfg: GroupSuiteConfig): void {
130
+ const { logger, addCommand, addMiddleware, useContext, onDispose } = plugin;
131
+
132
+ useContext("database", (db: any) => {
133
+ _db = db;
134
+ db.define("message_stats", {
135
+ user_id: { type: "text", nullable: false },
136
+ user_name: { type: "text", default: "" },
137
+ group_id: { type: "text", default: "" },
138
+ date: { type: "text", nullable: false },
139
+ count: { type: "integer", default: 0 },
140
+ updated_at: { type: "text", default: "" },
141
+ });
142
+ logger.info(formatCompact({ op: "stats_model" }));
143
+ });
144
+
145
+ const flushTimer = setInterval(flushBuffer, 10_000);
146
+ onDispose(async () => {
147
+ clearInterval(flushTimer);
148
+ await flushBuffer();
149
+ buffer.clear();
150
+ });
151
+
152
+ addMiddleware(async (message, next) => {
153
+ const userId = String(message.$sender?.id || "");
154
+ if (!userId) return next();
155
+ const key = bufferKey(userId, getGroupId(message), todayStr());
156
+ const pending = buffer.get(key);
157
+ if (pending) {
158
+ pending.count++;
159
+ pending.user_name = String(message.$sender?.name || "");
160
+ } else {
161
+ buffer.set(key, {
162
+ user_id: userId,
163
+ user_name: String(message.$sender?.name || ""),
164
+ group_id: getGroupId(message),
165
+ date: todayStr(),
166
+ count: 1,
167
+ });
168
+ }
169
+ return next();
170
+ });
171
+
172
+ addCommand(
173
+ new MessageCommand("stats")
174
+ .desc("消息统计", "查看今日消息统计")
175
+ .action(async (message) => {
176
+ await flushBuffer();
177
+ const groupId = getGroupId(message);
178
+ const stats = await queryStats(groupId, todayStr());
179
+ const title = groupId ? "今日本群消息统计" : "今日全局消息统计";
180
+ return formatRank(
181
+ stats,
182
+ title,
183
+ cfg.rankSize,
184
+ String(message.$sender?.id || ""),
185
+ );
186
+ }),
187
+ );
188
+
189
+ addCommand(
190
+ new MessageCommand("stats-week")
191
+ .desc("周消息统计", "查看本周消息统计")
192
+ .action(async (message) => {
193
+ await flushBuffer();
194
+ const groupId = getGroupId(message);
195
+ const stats = await queryStats(groupId, weekStartStr());
196
+ const title = groupId ? "本周本群消息统计" : "本周全局消息统计";
197
+ return formatRank(
198
+ stats,
199
+ title,
200
+ cfg.rankSize,
201
+ String(message.$sender?.id || ""),
202
+ );
203
+ }),
204
+ );
205
+
206
+ addCommand(
207
+ new MessageCommand("stats-rank")
208
+ .desc("话唠排行", "查看活跃度排行榜")
209
+ .action(async (message) => {
210
+ await flushBuffer();
211
+ const groupId = getGroupId(message);
212
+ const stats = await queryStats(groupId, monthStartStr());
213
+ const title = groupId ? "本月话唠排行" : "全局话唠排行";
214
+ return formatRank(
215
+ stats,
216
+ title,
217
+ cfg.rankSize,
218
+ String(message.$sender?.id || ""),
219
+ );
220
+ }),
221
+ );
222
+
223
+ addCommand(
224
+ new MessageCommand("mystats")
225
+ .desc("我的统计", "查看个人消息统计")
226
+ .action(async (message) => {
227
+ await flushBuffer();
228
+ const M = getModel();
229
+ if (!M) return "统计数据库尚未就绪";
230
+ const userId = String(message.$sender?.id || "");
231
+ if (!userId) return "无法获取用户信息";
232
+ const groupId = getGroupId(message);
233
+ const rows: any[] = groupId
234
+ ? await M.select().where({ user_id: userId, group_id: groupId })
235
+ : await M.select().where({ user_id: userId });
236
+ if (rows.length === 0) return "暂无你的消息记录";
237
+ const today = todayStr();
238
+ const weekStart = weekStartStr();
239
+ const monthStart = monthStartStr();
240
+ let todayCount = 0;
241
+ let weekCount = 0;
242
+ let monthCount = 0;
243
+ let totalCount = 0;
244
+ for (const row of rows) {
245
+ const c = row.count || 0;
246
+ totalCount += c;
247
+ if (row.date >= monthStart) monthCount += c;
248
+ if (row.date >= weekStart) weekCount += c;
249
+ if (row.date === today) todayCount += c;
250
+ }
251
+ const scope = groupId ? "本群" : "全局";
252
+ return [
253
+ `${message.$sender?.name || "你"} 的${scope}消息统计`,
254
+ `今日: ${todayCount} 条`,
255
+ `本周: ${weekCount} 条`,
256
+ `本月: ${monthCount} 条`,
257
+ `总计: ${totalCount} 条`,
258
+ `活跃天数: ${rows.length} 天`,
259
+ ].join("\n");
260
+ }),
261
+ );
262
+
263
+ const cleanupTimer = setInterval(async () => {
264
+ const M = getModel();
265
+ if (!M) return;
266
+ try {
267
+ const cutoff = new Date();
268
+ cutoff.setDate(cutoff.getDate() - cfg.statsRetentionDays);
269
+ const cutoffStr = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-${String(cutoff.getDate()).padStart(2, "0")}`;
270
+ const all: any[] = await M.select();
271
+ for (const row of all.filter((r) => r.date < cutoffStr)) {
272
+ await M.delete().where({ id: row.id });
273
+ }
274
+ } catch {
275
+ /* ignore */
276
+ }
277
+ }, 24 * 60 * 60_000);
278
+
279
+ onDispose(() => clearInterval(cleanupTimer));
280
+
281
+ logger.info(
282
+ formatCompact({
283
+ op: "stats_load",
284
+ rank_size: cfg.rankSize,
285
+ retention_days: cfg.statsRetentionDays,
286
+ }),
287
+ );
288
+ }
package/src/teach.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { MessageCommand, type Plugin } from "zhin.js";
2
+ import type { GroupSuiteConfig } from "./config.js";
3
+ import { getMessageContextKey, ts } from "./shared.js";
4
+
5
+ const cooldownMap = new Map<string, number>();
6
+
7
+ function isSafeRegex(pattern: string): boolean {
8
+ if (pattern.length > 200) return false;
9
+ if (/(\+|\*|\{)\)?(\+|\*|\{)/.test(pattern)) return false;
10
+ if (/\([^)]*(\+|\*)[^)]*(\+|\*)[^)]*\)[\+\*]/.test(pattern)) return false;
11
+ const altGroups = pattern.match(/\([^)]*\|[^)]*\)/g) || [];
12
+ for (const g of altGroups) {
13
+ if ((g.match(/\|/g) || []).length > 10) return false;
14
+ }
15
+ return true;
16
+ }
17
+
18
+ function processAnswer(
19
+ answer: string,
20
+ message: { $sender?: { name?: string; id?: string } },
21
+ match?: RegExpMatchArray,
22
+ ): string {
23
+ let result = answer;
24
+ result = result.replace(/\{sender\}/g, message.$sender?.name || "你");
25
+ result = result.replace(/\{sender\.id\}/g, message.$sender?.id || "");
26
+ result = result.replace(/\{time\}/g, new Date().toLocaleTimeString("zh-CN"));
27
+ result = result.replace(/\{date\}/g, new Date().toLocaleDateString("zh-CN"));
28
+ if (match) {
29
+ for (let i = 1; i < match.length; i++) {
30
+ result = result.replace(new RegExp(`\\$${i}`, "g"), match[i] || "");
31
+ }
32
+ }
33
+ return result;
34
+ }
35
+
36
+ let _db: any = null;
37
+
38
+ function getQA(): any {
39
+ return _db?.models?.get("teach_qa") ?? null;
40
+ }
41
+
42
+ export function registerTeach(plugin: Plugin, cfg: GroupSuiteConfig): void {
43
+ const { addCommand, addMiddleware, useContext, onDispose } = plugin;
44
+
45
+ useContext("database", (db: any) => {
46
+ _db = db;
47
+ db.define("teach_qa", {
48
+ question: { type: "text", nullable: false },
49
+ answer: { type: "text", nullable: false },
50
+ is_regex: { type: "integer", default: 0 },
51
+ context_type: { type: "text", default: "global" },
52
+ context_id: { type: "text", default: "" },
53
+ creator_id: { type: "text", default: "" },
54
+ creator_name: { type: "text", default: "" },
55
+ hit_count: { type: "integer", default: 0 },
56
+ created_at: { type: "text", default: "" },
57
+ updated_at: { type: "text", default: "" },
58
+ });
59
+ });
60
+
61
+ const cooldownCleanup = setInterval(() => {
62
+ const now = Date.now();
63
+ for (const [key, time] of cooldownMap) {
64
+ if (now - time > cfg.teachCooldownMs * 2) cooldownMap.delete(key);
65
+ }
66
+ }, 60_000);
67
+ onDispose(() => clearInterval(cooldownCleanup));
68
+
69
+ async function findMatch(
70
+ content: string,
71
+ ctxType: string,
72
+ ctxId: string,
73
+ ): Promise<any | null> {
74
+ const QA = getQA();
75
+ if (!QA) return null;
76
+ const allItems: any[] = await QA.select();
77
+ const candidates = allItems.filter(
78
+ (item) =>
79
+ item.context_type === "global" ||
80
+ (item.context_type === ctxType && item.context_id === ctxId),
81
+ );
82
+ for (const item of candidates) {
83
+ if (!item.is_regex && item.question === content) return item;
84
+ }
85
+ if (cfg.teachAllowRegex) {
86
+ for (const item of candidates) {
87
+ if (!item.is_regex) continue;
88
+ try {
89
+ if (new RegExp(item.question, "i").test(content)) return item;
90
+ } catch {
91
+ continue;
92
+ }
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
98
+ addMiddleware(async (message, next) => {
99
+ const content = (message.$raw || "").trim();
100
+ if (!content) return next();
101
+ const { type: ctxType, id: ctxId } = getMessageContextKey(message);
102
+ const matched = await findMatch(content, ctxType, ctxId);
103
+ if (!matched) return next();
104
+ const cooldownKey = `${matched.id}:${ctxType}:${ctxId}`;
105
+ const last = cooldownMap.get(cooldownKey);
106
+ if (last && Date.now() - last < cfg.teachCooldownMs) return next();
107
+ cooldownMap.set(cooldownKey, Date.now());
108
+ const QA = getQA();
109
+ if (QA) {
110
+ try {
111
+ await QA.update({
112
+ hit_count: (matched.hit_count || 0) + 1,
113
+ updated_at: ts(),
114
+ }).where({ id: matched.id });
115
+ } catch {
116
+ /* ignore */
117
+ }
118
+ }
119
+ let answer = matched.answer;
120
+ if (matched.is_regex) {
121
+ try {
122
+ const m = content.match(new RegExp(matched.question, "i"));
123
+ answer = processAnswer(answer, message, m || undefined);
124
+ } catch {
125
+ /* ignore */
126
+ }
127
+ } else {
128
+ answer = processAnswer(answer, message);
129
+ }
130
+ await message.$reply(answer);
131
+ });
132
+
133
+ addCommand(
134
+ new MessageCommand("teach <question:text> <answer:text>")
135
+ .desc("教我问答", "教会 Bot 一个新的问答对")
136
+ .action(async (message, result) => {
137
+ const QA = getQA();
138
+ if (!QA) return "问答数据库尚未就绪,请稍后重试";
139
+ const question = result.params.question?.trim();
140
+ const answer = result.params.answer?.trim();
141
+ if (!question || !answer) return "请提供问题和回答";
142
+ const { type: ctxType, id: ctxId } = getMessageContextKey(message);
143
+ if (ctxType === "group") {
144
+ const existing: any[] = await QA.select().where({
145
+ context_type: ctxType,
146
+ context_id: ctxId,
147
+ });
148
+ if (existing.length >= cfg.teachMaxPerGroup) {
149
+ return `该群问答数已达上限 (${cfg.teachMaxPerGroup})`;
150
+ }
151
+ }
152
+ const duplicate: any[] = await QA.select().where({
153
+ question,
154
+ context_type: ctxType,
155
+ context_id: ctxId,
156
+ });
157
+ if (duplicate.length > 0) {
158
+ await QA.update({ answer, updated_at: ts() }).where({
159
+ id: duplicate[0].id,
160
+ });
161
+ return `已更新问答:「${question}」→「${answer}」`;
162
+ }
163
+ await QA.insert({
164
+ question,
165
+ answer,
166
+ is_regex: 0,
167
+ context_type: ctxType,
168
+ context_id: ctxId,
169
+ creator_id: message.$sender?.id || "",
170
+ creator_name: message.$sender?.name || "",
171
+ hit_count: 0,
172
+ created_at: ts(),
173
+ updated_at: ts(),
174
+ });
175
+ return `学会了!发送「${question}」我会回复「${answer}」`;
176
+ }),
177
+ );
178
+
179
+ addCommand(
180
+ new MessageCommand("teach-regex <pattern:text> <answer:text>")
181
+ .desc("教我正则问答", "用正则匹配")
182
+ .action(async (message, result) => {
183
+ const QA = getQA();
184
+ if (!QA) return "问答数据库尚未就绪";
185
+ if (!cfg.teachAllowRegex) return "管理员已禁用正则问答";
186
+ const pattern = result.params.pattern?.trim();
187
+ const answer = result.params.answer?.trim();
188
+ if (!pattern || !answer) return "请提供正则和回答";
189
+ try {
190
+ new RegExp(pattern, "i");
191
+ } catch (e) {
192
+ return `正则表达式不合法: ${(e as Error).message}`;
193
+ }
194
+ if (!isSafeRegex(pattern)) return "该正则可能导致性能问题,请简化";
195
+ const { type: ctxType, id: ctxId } = getMessageContextKey(message);
196
+ await QA.insert({
197
+ question: pattern,
198
+ answer,
199
+ is_regex: 1,
200
+ context_type: ctxType,
201
+ context_id: ctxId,
202
+ creator_id: message.$sender?.id || "",
203
+ creator_name: message.$sender?.name || "",
204
+ hit_count: 0,
205
+ created_at: ts(),
206
+ updated_at: ts(),
207
+ });
208
+ return `学会了!匹配 /${pattern}/ 的消息我会回复「${answer}」`;
209
+ }),
210
+ );
211
+
212
+ addCommand(
213
+ new MessageCommand("forget <question:text>")
214
+ .desc("忘记问答", "删除问答对")
215
+ .action(async (message, result) => {
216
+ const QA = getQA();
217
+ if (!QA) return "问答数据库尚未就绪";
218
+ const question = result.params.question?.trim();
219
+ if (!question) return "请提供要删除的问题";
220
+ const { type: ctxType, id: ctxId } = getMessageContextKey(message);
221
+ const items: any[] = await QA.select().where({
222
+ question,
223
+ context_type: ctxType,
224
+ context_id: ctxId,
225
+ });
226
+ if (items.length === 0) return `没有找到问题「${question}」`;
227
+ await QA.delete().where({ id: items[0].id });
228
+ return `已忘记「${question}」的回答`;
229
+ }),
230
+ );
231
+
232
+ addCommand(
233
+ new MessageCommand("teach-list [page:number]")
234
+ .desc("问答列表", "查看问答对")
235
+ .action(async (message, result) => {
236
+ const QA = getQA();
237
+ if (!QA) return "问答数据库尚未就绪";
238
+ const page = Math.max(1, result.params.page || 1);
239
+ const { type: ctxType, id: ctxId } = getMessageContextKey(message);
240
+ const allItems: any[] = await QA.select().where(
241
+ ctxType === "global"
242
+ ? { context_type: "global" }
243
+ : { context_type: ctxType, context_id: ctxId },
244
+ );
245
+ if (allItems.length === 0) return "还没有教过任何问答哦~";
246
+ const totalPages = Math.ceil(allItems.length / cfg.teachPageSize);
247
+ const safePage = Math.min(page, totalPages);
248
+ const start = (safePage - 1) * cfg.teachPageSize;
249
+ const pageItems = allItems.slice(start, start + cfg.teachPageSize);
250
+ const lines = pageItems.map((item, i) => {
251
+ const prefix = item.is_regex ? "[正则]" : "[精确]";
252
+ const q = item.is_regex ? `/${item.question}/` : item.question;
253
+ return `${start + i + 1}. ${prefix} ${q} → ${item.answer}`;
254
+ });
255
+ const header = ctxType === "global" ? "全局问答列表" : "本群问答列表";
256
+ return `${header}\n${lines.join("\n")}\n第 ${safePage}/${totalPages} 页 · 共 ${allItems.length} 条`;
257
+ }),
258
+ );
259
+ }
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: checkin_query
3
+ description: 查询用户的签到积分信息
4
+ parameters:
5
+ user_id:
6
+ type: string
7
+ description: 用户ID(可选,不填则返回统计摘要)
8
+ tags: [签到, 积分, 查询]
9
+ keywords: [签到, 积分, 查询积分]
10
+ handler: ./handler.ts
11
+ ---
@@ -0,0 +1,21 @@
1
+ import { getModel, todayStr } from '../../src/index.js';
2
+
3
+ export default async function (args: { user_id?: string }) {
4
+ const M = getModel();
5
+ if (!M) return '签到数据库尚未就绪';
6
+
7
+ const userId = args.user_id;
8
+ if (userId) {
9
+ const rows: any[] = await M.select().where({ user_id: userId });
10
+ if (rows.length === 0) return `用户 ${userId} 没有签到记录`;
11
+ const u = rows[0];
12
+ return `${u.user_name}: 积分=${u.points}, 累计=${u.total_checkins}天, 连续=${u.streak}天, 最长=${u.max_streak}天, 上次=${u.last_checkin}`;
13
+ }
14
+
15
+ const all: any[] = await M.select();
16
+ const totalUsers = all.length;
17
+ const totalPoints = all.reduce((s: number, u: any) => s + (u.points || 0), 0);
18
+ const today = todayStr();
19
+ const todayCount = all.filter((u: any) => u.last_checkin === today).length;
20
+ return `签到系统统计\n总用户: ${totalUsers}\n总积分: ${totalPoints}\n今日签到: ${todayCount}人`;
21
+ }
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: checkin_rank
3
+ description: 获取积分排行榜数据
4
+ parameters:
5
+ limit:
6
+ type: number
7
+ description: 返回前 N 名(默认10)
8
+ tags: [签到, 排行]
9
+ keywords: [排行, 积分排行, 签到排行]
10
+ handler: ./handler.ts
11
+ ---
@@ -0,0 +1,16 @@
1
+ import { getModel } from '../../src/index.js';
2
+
3
+ export default async function (args: { limit?: number }) {
4
+ const M = getModel();
5
+ if (!M) return '签到数据库尚未就绪';
6
+
7
+ const limit = Math.min(Math.max(Number(args.limit) || 10, 1), 50);
8
+ const all: any[] = await M.select();
9
+ const sorted = all.sort((a: any, b: any) => (b.points || 0) - (a.points || 0)).slice(0, limit);
10
+
11
+ if (sorted.length === 0) return '暂无排行数据';
12
+
13
+ return sorted
14
+ .map((u: any, i: number) => `${i + 1}. ${u.user_name || u.user_id} — ${u.points}分 (连续${u.streak}天)`)
15
+ .join('\n');
16
+ }
@@ -0,0 +1,25 @@
1
+ import { flushBuffer, queryStats, todayStr, weekStartStr, monthStartStr } from '../../src/index.js';
2
+
3
+ export default async function (args: { group_id?: string; period?: string }) {
4
+ await flushBuffer();
5
+ const groupId = args.group_id || '';
6
+ const period = args.period || 'today';
7
+
8
+ let fromDate: string;
9
+ switch (period) {
10
+ case 'week': fromDate = weekStartStr(); break;
11
+ case 'month': fromDate = monthStartStr(); break;
12
+ default: fromDate = todayStr(); break;
13
+ }
14
+
15
+ const stats = await queryStats(groupId, fromDate);
16
+ if (stats.size === 0) return `${period} 暂无消息统计数据`;
17
+
18
+ const sorted = [...stats.entries()].sort((a, b) => b[1].count - a[1].count);
19
+ const total = sorted.reduce((s, [, v]) => s + v.count, 0);
20
+
21
+ const top10 = sorted.slice(0, 10);
22
+ const lines = top10.map(([, v], i) => `${i + 1}. ${v.name} — ${v.count}条`);
23
+
24
+ return `消息统计 (${period})\n总消息: ${total}条, 活跃用户: ${stats.size}人\n${lines.join('\n')}`;
25
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: stats_query
3
+ description: 查询消息统计数据
4
+ parameters:
5
+ group_id:
6
+ type: string
7
+ description: 群ID(可选,不填查全局)
8
+ period:
9
+ type: string
10
+ description: "时段: today/week/month(默认today)"
11
+ enum: [today, week, month]
12
+ default: today
13
+ tags: [统计, 消息]
14
+ keywords: [统计, 消息统计, 消息量]
15
+ handler: ./handler.ts
16
+ ---
@@ -0,0 +1,27 @@
1
+ import { flushBuffer, getModel, todayStr, weekStartStr } from '../../src/index.js';
2
+
3
+ export default async function (args: { user_id: string }) {
4
+ await flushBuffer();
5
+ const M = getModel();
6
+ if (!M) return '统计数据库尚未就绪';
7
+
8
+ const userId = args.user_id;
9
+ if (!userId) return '请提供用户ID';
10
+
11
+ const rows: any[] = await M.select().where({ user_id: userId });
12
+ if (rows.length === 0) return `用户 ${userId} 暂无消息记录`;
13
+
14
+ const today = todayStr();
15
+ const weekStart = weekStartStr();
16
+ let todayCount = 0, weekCount = 0, totalCount = 0;
17
+
18
+ for (const row of rows) {
19
+ const c = row.count || 0;
20
+ totalCount += c;
21
+ if (row.date >= weekStart) weekCount += c;
22
+ if (row.date === today) todayCount += c;
23
+ }
24
+
25
+ const name = rows[0].user_name || userId;
26
+ return `${name} 的统计\n今日: ${todayCount}条\n本周: ${weekCount}条\n总计: ${totalCount}条\n活跃天数: ${rows.length}天`;
27
+ }
@@ -0,0 +1,12 @@
1
+ ---
2
+ name: stats_user
3
+ description: 查询指定用户的消息统计
4
+ parameters:
5
+ user_id:
6
+ type: string
7
+ description: 用户ID
8
+ required: true
9
+ tags: [统计, 用户]
10
+ keywords: [用户统计, 消息统计, 个人统计]
11
+ handler: ./handler.ts
12
+ ---