@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
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: group-daily-analysis
3
+ description: >-
4
+ 群日常分析:基于收件箱统计群消息数量、参与人数、活跃时段,
5
+ 支持按群启用/禁用和定时分析报告。通过命令和定时任务运行,无 AI 工具。
6
+ keywords:
7
+ - 群分析
8
+ - 日常分析
9
+ - 群统计
10
+ - 活跃
11
+ - 报告
12
+ tags:
13
+ - group
14
+ - analysis
15
+ - stats
16
+ - inbox
17
+ tools: []
18
+ ---
19
+
20
+ # 群日常分析
21
+
22
+ 基于收件箱数据自动分析群活跃度。无 AI 工具可调用。
23
+
24
+ ## 能力
25
+
26
+ - 统计群消息数量、参与人数、活跃时段分布
27
+ - 支持按群启用/禁用分析功能
28
+ - 定时任务自动生成分析报告推送到群内
29
+ - 通过聊天命令管理分析设置
package/src/admin.ts ADDED
@@ -0,0 +1,145 @@
1
+ import {
2
+ formatCompact,
3
+ MessageCommand,
4
+ type Plugin,
5
+ ZhinTool,
6
+ } from "zhin.js";
7
+ import { adapterAllowed } from "./shared.js";
8
+ import type { GroupSuiteConfig } from "./config.js";
9
+
10
+ export function registerAdmin(plugin: Plugin, cfg: GroupSuiteConfig): void {
11
+ const { addCommand, addMiddleware, addTool, logger } = plugin;
12
+ const welcomeTemplate = cfg.welcome;
13
+ const noticeAdapters = cfg.noticeAdapters;
14
+
15
+ plugin.on("notice.receive", async (notice) => {
16
+ if (!adapterAllowed(noticeAdapters, String(notice.$adapter))) return;
17
+
18
+ if (notice.$type === "group_member_increase") {
19
+ if (notice.$channel.type !== "group") return;
20
+ const targetName =
21
+ notice.$target?.name || notice.$target?.id || "新成员";
22
+ const welcomeMsg = `🎉 ${targetName},${welcomeTemplate}`;
23
+ try {
24
+ const adapter = plugin.inject(notice.$adapter as never) as any;
25
+ if (!adapter?.sendMessage) return;
26
+ await adapter.sendMessage({
27
+ context: notice.$adapter,
28
+ bot: notice.$bot,
29
+ content: welcomeMsg,
30
+ id: notice.$channel.id,
31
+ type: notice.$channel.type,
32
+ });
33
+ } catch (e: unknown) {
34
+ logger.warn(
35
+ formatCompact({
36
+ op: "welcome",
37
+ ok: false,
38
+ error: e instanceof Error ? e.message : String(e),
39
+ }),
40
+ );
41
+ }
42
+ return;
43
+ }
44
+
45
+ if (cfg.recallNotify && notice.$type === "group_recall") {
46
+ if (notice.$channel.type !== "group") return;
47
+ const operatorName =
48
+ notice.$operator?.name || notice.$operator?.id || "某人";
49
+ const msg = `⚠️ ${operatorName} 撤回了一条消息`;
50
+ try {
51
+ const adapter = plugin.inject(notice.$adapter as never) as any;
52
+ if (!adapter?.sendMessage) return;
53
+ await adapter.sendMessage({
54
+ context: notice.$adapter,
55
+ bot: notice.$bot,
56
+ content: msg,
57
+ id: notice.$channel.id,
58
+ type: notice.$channel.type,
59
+ });
60
+ } catch (e: unknown) {
61
+ logger.warn(
62
+ formatCompact({
63
+ op: "recall_notice",
64
+ ok: false,
65
+ error: e instanceof Error ? e.message : String(e),
66
+ }),
67
+ );
68
+ }
69
+ }
70
+ });
71
+
72
+ if (!cfg.keywordReply) return;
73
+
74
+ const keywords = new Map<string, string>();
75
+
76
+ addMiddleware(async (message, next) => {
77
+ if (message.$channel.type !== "group") {
78
+ await next();
79
+ return;
80
+ }
81
+ const raw = message.$raw?.trim();
82
+ if (!raw) {
83
+ await next();
84
+ return;
85
+ }
86
+ for (const [keyword, reply] of keywords) {
87
+ if (raw.includes(keyword)) {
88
+ await message.$reply(reply);
89
+ return;
90
+ }
91
+ }
92
+ await next();
93
+ });
94
+
95
+ addCommand(
96
+ new MessageCommand("添加关键词 <keyword:text> <reply:text>")
97
+ .desc("添加一个关键词自动回复对")
98
+ .action(async (_message, result) => {
99
+ const { keyword, reply } = result.params as {
100
+ keyword: string;
101
+ reply: string;
102
+ };
103
+ if (!keyword || !reply) return "请提供关键词和回复内容";
104
+ keywords.set(keyword, reply);
105
+ return `已添加关键词「${keyword}」→「${reply}」`;
106
+ }),
107
+ );
108
+
109
+ addCommand(
110
+ new MessageCommand("删除关键词 <keyword:text>")
111
+ .desc("删除一个关键词")
112
+ .action(async (_message, result) => {
113
+ const { keyword } = result.params as { keyword: string };
114
+ if (!keyword) return "请提供要删除的关键词";
115
+ if (!keywords.has(keyword)) return `关键词「${keyword}」不存在`;
116
+ keywords.delete(keyword);
117
+ return `已删除关键词「${keyword}」`;
118
+ }),
119
+ );
120
+
121
+ addCommand(
122
+ new MessageCommand("关键词列表")
123
+ .desc("查看所有关键词回复对")
124
+ .action(async () => {
125
+ if (keywords.size === 0) return "当前没有设置任何关键词";
126
+ const lines = Array.from(keywords.entries()).map(
127
+ ([k, v], i) => `${i + 1}. 「${k}」→「${v}」`,
128
+ );
129
+ return `关键词列表(共 ${keywords.size} 条):\n${lines.join("\n")}`;
130
+ }),
131
+ );
132
+
133
+ addTool(
134
+ new ZhinTool("group_announce")
135
+ .desc("向群聊发送公告/通知消息")
136
+ .keyword("群公告", "通知", "announce")
137
+ .param("message", { type: "string", description: "要发送的公告内容" }, true)
138
+ .execute(async (args) => {
139
+ const content = args.message as string;
140
+ if (!content?.trim()) return "公告内容不能为空";
141
+ return `📢 群公告:\n${content}`;
142
+ })
143
+ .toTool(),
144
+ );
145
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * 群日常分析:从收件箱查询、基础统计、文本报告。
3
+ * 数据源为 zhin 内置 unified_inbox_message 表(需 inbox.enabled)。
4
+ *
5
+ * 话题/金句/用户画像分析逻辑灵感来自:
6
+ * https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis
7
+ */
8
+
9
+ export interface InboxMessageRow {
10
+ id?: number;
11
+ adapter: string;
12
+ bot_id: string;
13
+ channel_id: string;
14
+ channel_type: string;
15
+ sender_id: string;
16
+ sender_name: string | null;
17
+ content: string;
18
+ /** 收件箱里可能是字符串,也可能被 ORM/驱动解析成对象 */
19
+ raw: unknown;
20
+ created_at: number;
21
+ }
22
+
23
+ export interface BasicStats {
24
+ messageCount: number;
25
+ participantCount: number;
26
+ totalChars: number;
27
+ hourlyDistribution: Record<number, number>;
28
+ mostActiveHour: number;
29
+ }
30
+
31
+ /** 话题摘要(灵感来自 astrbot_plugin_qq_group_daily_analysis) */
32
+ export interface SummaryTopic {
33
+ topic: string;
34
+ summary?: string;
35
+ }
36
+
37
+ /** 金句(灵感来自 astrbot_plugin_qq_group_daily_analysis) */
38
+ export interface GoldenQuote {
39
+ content: string;
40
+ sender: string;
41
+ reason: string;
42
+ }
43
+
44
+ /** 用户称号/画像(灵感来自 astrbot_plugin_qq_group_daily_analysis) */
45
+ export interface UserTitle {
46
+ name: string;
47
+ user_id: string;
48
+ title: string;
49
+ reason?: string;
50
+ }
51
+
52
+ export interface LLMAnalysis {
53
+ topics: SummaryTopic[];
54
+ quotes: GoldenQuote[];
55
+ userTitles: UserTitle[];
56
+ }
57
+
58
+ export interface AnalysisResult {
59
+ stats: BasicStats;
60
+ textReport: string;
61
+ llm?: LLMAnalysis;
62
+ }
63
+
64
+ /**
65
+ * 从 content (JSON) 或 raw 提取纯文本,用于统计与 LLM
66
+ */
67
+ export function extractText(row: InboxMessageRow): string {
68
+ const raw = row.raw;
69
+ if (typeof raw === "string") {
70
+ const t = raw.trim();
71
+ if (t) return t;
72
+ }
73
+ // 非字符串 raw(如 icqq 存的对象)不走 trim,避免报错;改从 content 解析
74
+ try {
75
+ const c = row.content;
76
+ const contentStr = typeof c === "string" ? c : JSON.stringify(c ?? []);
77
+ const content = JSON.parse(contentStr || "[]");
78
+ if (typeof content === "string") return content;
79
+ if (Array.isArray(content)) {
80
+ return content
81
+ .map((part: any) => {
82
+ if (part?.type === "text") {
83
+ return part.data?.text ?? part.text ?? "";
84
+ }
85
+ return part?.text ?? "";
86
+ })
87
+ .filter(Boolean)
88
+ .join("");
89
+ }
90
+ } catch {
91
+ // ignore
92
+ }
93
+ return "";
94
+ }
95
+
96
+ /**
97
+ * 计算基础统计:消息数、人数、总字数、按小时分布、最活跃时段
98
+ */
99
+ export function computeBasicStats(rows: InboxMessageRow[]): BasicStats {
100
+ const participantIds = new Set<string>();
101
+ let totalChars = 0;
102
+ const hourly: Record<number, number> = {};
103
+ for (let h = 0; h < 24; h++) hourly[h] = 0;
104
+
105
+ for (const row of rows) {
106
+ participantIds.add(row.sender_id);
107
+ const text = extractText(row);
108
+ totalChars += text.length;
109
+ const date = new Date(row.created_at);
110
+ const h = date.getHours();
111
+ hourly[h] = (hourly[h] || 0) + 1;
112
+ }
113
+
114
+ let mostActiveHour = 0;
115
+ let maxCount = 0;
116
+ for (let h = 0; h < 24; h++) {
117
+ if (hourly[h] > maxCount) {
118
+ maxCount = hourly[h];
119
+ mostActiveHour = h;
120
+ }
121
+ }
122
+
123
+ return {
124
+ messageCount: rows.length,
125
+ participantCount: participantIds.size,
126
+ totalChars,
127
+ hourlyDistribution: hourly,
128
+ mostActiveHour,
129
+ };
130
+ }
131
+
132
+ /**
133
+ * 生成纯文本报告(不含 LLM 内容)
134
+ */
135
+ export function formatTextReport(
136
+ stats: BasicStats,
137
+ options: { channelName?: string; days: number; startDate: string; endDate: string }
138
+ ): string {
139
+ const lines: string[] = [];
140
+ const title = options.channelName
141
+ ? `【${options.channelName}】群日常分析`
142
+ : "群日常分析";
143
+ lines.push(`📊 ${title}`);
144
+ lines.push(`📅 统计区间:${options.startDate} 至 ${options.endDate}(最近 ${options.days} 天)`);
145
+ lines.push("");
146
+ lines.push(`💬 消息总数:${stats.messageCount} 条`);
147
+ lines.push(`👥 参与人数:${stats.participantCount} 人`);
148
+ lines.push(`📝 总字数:${stats.totalChars} 字`);
149
+ lines.push(`⏰ 最活跃时段:${stats.mostActiveHour}:00 - ${stats.mostActiveHour + 1}:00`);
150
+ lines.push("");
151
+ lines.push("📈 每小时消息分布:");
152
+ const maxH = Math.max(...Object.values(stats.hourlyDistribution), 1);
153
+ for (let h = 0; h < 24; h++) {
154
+ const c = stats.hourlyDistribution[h] || 0;
155
+ const bar = "█".repeat(Math.round((c / maxH) * 10)) || "░";
156
+ lines.push(` ${String(h).padStart(2, "0")}:00 ${bar} ${c}`);
157
+ }
158
+ return lines.join("\n");
159
+ }
160
+
161
+ /**
162
+ * 将 LLM 分析结果追加到文本报告。
163
+ * 话题/金句/用户画像 设计灵感来自 astrbot_plugin_qq_group_daily_analysis
164
+ */
165
+ export function appendLLMReport(textReport: string, llm: LLMAnalysis): string {
166
+ const lines: string[] = [textReport, ""];
167
+ if (llm.topics?.length) {
168
+ lines.push("🔥 热门话题(LLM 提取):");
169
+ llm.topics.forEach((t, i) => {
170
+ lines.push(` ${i + 1}. ${t.topic}${t.summary ? ` — ${t.summary}` : ""}`);
171
+ });
172
+ lines.push("");
173
+ }
174
+ if (llm.quotes?.length) {
175
+ lines.push("💬 金句(LLM 筛选):");
176
+ llm.quotes.forEach((q, i) => {
177
+ lines.push(` ${i + 1}. 「${q.content}」 — ${q.sender}(${q.reason})`);
178
+ });
179
+ lines.push("");
180
+ }
181
+ if (llm.userTitles?.length) {
182
+ lines.push("👤 用户画像/称号(LLM):");
183
+ llm.userTitles.forEach((u, i) => {
184
+ lines.push(` ${i + 1}. ${u.name} — ${u.title}${u.reason ? `(${u.reason})` : ""}`);
185
+ });
186
+ }
187
+ return lines.join("\n");
188
+ }
189
+
190
+ /**
191
+ * 从 LLM 返回的文本中解析出 topics / quotes / userTitles。
192
+ * 约定 LLM 返回 JSON 或类似结构,此处做宽松解析。
193
+ */
194
+ export function parseLLMResponse(raw: string): LLMAnalysis {
195
+ const result: LLMAnalysis = { topics: [], quotes: [], userTitles: [] };
196
+ try {
197
+ const trimmed = raw.trim();
198
+ const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
199
+ if (jsonMatch) {
200
+ const obj = JSON.parse(jsonMatch[0]) as Record<string, unknown>;
201
+ if (Array.isArray(obj.topics)) {
202
+ result.topics = obj.topics.map((t: any) => ({
203
+ topic: typeof t.topic === "string" ? t.topic : String(t.topic || ""),
204
+ summary: typeof t.summary === "string" ? t.summary : undefined,
205
+ }));
206
+ }
207
+ if (Array.isArray(obj.quotes)) {
208
+ result.quotes = obj.quotes.map((q: any) => ({
209
+ content: String(q.content ?? ""),
210
+ sender: String(q.sender ?? ""),
211
+ reason: String(q.reason ?? ""),
212
+ }));
213
+ }
214
+ if (Array.isArray(obj.user_titles)) {
215
+ result.userTitles = obj.user_titles.map((u: any) => ({
216
+ name: String(u.name ?? ""),
217
+ user_id: String(u.user_id ?? ""),
218
+ title: String(u.title ?? ""),
219
+ reason: typeof u.reason === "string" ? u.reason : undefined,
220
+ }));
221
+ }
222
+ }
223
+ } catch {
224
+ // ignore parse errors
225
+ }
226
+ return result;
227
+ }
package/src/checkin.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { formatCompact, MessageCommand, type Plugin } from "zhin.js";
2
+ import type { GroupSuiteConfig } from "./config.js";
3
+ import { getDatabase, getMessageContextKey, todayStr, ts } from "./shared.js";
4
+
5
+ function yesterdayStr(): string {
6
+ const d = new Date();
7
+ d.setDate(d.getDate() - 1);
8
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
9
+ }
10
+
11
+ function randomInt(min: number, max: number): number {
12
+ return Math.floor(Math.random() * (max - min + 1)) + min;
13
+ }
14
+
15
+ let _db: any = null;
16
+
17
+ function getModel(): any {
18
+ return _db?.models?.get("checkin_records") ?? null;
19
+ }
20
+
21
+ export function registerCheckin(plugin: Plugin, cfg: GroupSuiteConfig): void {
22
+ const { logger, addCommand, useContext } = plugin;
23
+
24
+ useContext("database", (db: any) => {
25
+ _db = db;
26
+ db.define("checkin_records", {
27
+ user_id: { type: "text", nullable: false },
28
+ user_name: { type: "text", default: "" },
29
+ points: { type: "integer", default: 0 },
30
+ total_checkins: { type: "integer", default: 0 },
31
+ streak: { type: "integer", default: 0 },
32
+ max_streak: { type: "integer", default: 0 },
33
+ last_checkin: { type: "text", default: "" },
34
+ context_type: { type: "text", default: "global" },
35
+ context_id: { type: "text", default: "" },
36
+ created_at: { type: "text", default: "" },
37
+ updated_at: { type: "text", default: "" },
38
+ });
39
+ logger.info(formatCompact({ op: "checkin_model" }));
40
+ });
41
+
42
+ async function getOrCreateUser(
43
+ model: any,
44
+ userId: string,
45
+ userName: string,
46
+ ctxType: string,
47
+ ctxId: string,
48
+ ): Promise<Record<string, unknown> | null> {
49
+ const rows = (await model.select().where({
50
+ user_id: userId,
51
+ context_type: ctxType,
52
+ context_id: ctxId,
53
+ })) as Record<string, unknown>[];
54
+ if (rows.length > 0) return rows[0];
55
+
56
+ await model.insert({
57
+ user_id: userId,
58
+ user_name: userName,
59
+ points: 0,
60
+ total_checkins: 0,
61
+ streak: 0,
62
+ max_streak: 0,
63
+ last_checkin: "",
64
+ context_type: ctxType,
65
+ context_id: ctxId,
66
+ created_at: ts(),
67
+ updated_at: ts(),
68
+ });
69
+
70
+ const created = (await model.select().where({
71
+ user_id: userId,
72
+ context_type: ctxType,
73
+ context_id: ctxId,
74
+ })) as Record<string, unknown>[];
75
+ return created[0] ?? null;
76
+ }
77
+
78
+ addCommand(
79
+ new MessageCommand("checkin")
80
+ .desc("签到", "每日签到获得积分")
81
+ .action(async (message) => {
82
+ const M = getModel();
83
+ if (!M) return "签到数据库尚未就绪,请稍后重试";
84
+
85
+ const userId = String(message.$sender?.id || "");
86
+ const userName = String(message.$sender?.name || "用户");
87
+ if (!userId) return "无法获取用户信息";
88
+
89
+ const { type: ctxType, id: ctxId } = getMessageContextKey(message);
90
+ const user = await getOrCreateUser(M, userId, userName, ctxType, ctxId);
91
+ if (!user) return "签到失败,请重试";
92
+
93
+ const today = todayStr();
94
+ if (user.last_checkin === today) {
95
+ return `你今天已经签到过了哦~\n当前积分: ${user.points}\n连续签到: ${user.streak} 天`;
96
+ }
97
+
98
+ const isConsecutive = user.last_checkin === yesterdayStr();
99
+ const newStreak = isConsecutive ? Number(user.streak) + 1 : 1;
100
+ const base = randomInt(cfg.basePointsMin, cfg.basePointsMax);
101
+ const bonus = Math.min(newStreak * cfg.streakBonus, cfg.streakCap);
102
+ const earned = base + bonus;
103
+ const newPoints = Number(user.points || 0) + earned;
104
+
105
+ await M.update({
106
+ points: newPoints,
107
+ total_checkins: Number(user.total_checkins || 0) + 1,
108
+ streak: newStreak,
109
+ max_streak: Math.max(Number(user.max_streak || 0), newStreak),
110
+ last_checkin: today,
111
+ user_name: userName,
112
+ updated_at: ts(),
113
+ }).where({ id: user.id });
114
+
115
+ const lines = [`签到成功!`, `基础积分: +${base}`];
116
+ if (bonus > 0) lines.push(`连续奖励: +${bonus} (连续${newStreak}天)`);
117
+ lines.push(`本次获得: +${earned}`, `当前积分: ${newPoints}`);
118
+ return lines.join("\n");
119
+ }),
120
+ );
121
+
122
+ addCommand(
123
+ new MessageCommand("mypoints")
124
+ .desc("我的积分", "查看个人积分与签到信息")
125
+ .action(async (message) => {
126
+ const M = getModel();
127
+ if (!M) return "签到数据库尚未就绪";
128
+ const userId = String(message.$sender?.id || "");
129
+ const { type: ctxType, id: ctxId } = getMessageContextKey(message);
130
+ const rows = (await M.select().where({
131
+ user_id: userId,
132
+ context_type: ctxType,
133
+ context_id: ctxId,
134
+ })) as Record<string, unknown>[];
135
+ if (rows.length === 0) return "你还没有签到记录哦,发送「签到」开始吧!";
136
+ const u = rows[0];
137
+ const checkedToday = u.last_checkin === todayStr();
138
+ return [
139
+ `${u.user_name || "用户"} 的积分信息`,
140
+ `积分: ${u.points}`,
141
+ `累计签到: ${u.total_checkins} 天`,
142
+ `连续签到: ${u.streak} 天`,
143
+ `最长连续: ${u.max_streak} 天`,
144
+ checkedToday ? "今日已签到 ✓" : "今日未签到",
145
+ ].join("\n");
146
+ }),
147
+ );
148
+
149
+ addCommand(
150
+ new MessageCommand("rank")
151
+ .desc("积分排行", "查看积分排行榜")
152
+ .action(async (message) => {
153
+ const M = getModel();
154
+ if (!M) return "签到数据库尚未就绪";
155
+ const { type: ctxType, id: ctxId } = getMessageContextKey(message);
156
+ const all = (await M.select().where(
157
+ ctxType === "global"
158
+ ? { context_type: "global" }
159
+ : { context_type: ctxType, context_id: ctxId },
160
+ )) as Record<string, unknown>[];
161
+ if (all.length === 0) return "还没有人签到过哦~";
162
+ const sorted = all.sort(
163
+ (a, b) => Number(b.points || 0) - Number(a.points || 0),
164
+ );
165
+ const top = sorted.slice(0, cfg.rankSize);
166
+ const medals = ["🥇", "🥈", "🥉"];
167
+ const lines = top.map((u, i) => {
168
+ const prefix = i < 3 ? medals[i] : `${i + 1}.`;
169
+ return `${prefix} ${u.user_name || u.user_id} — ${u.points}分 (${u.total_checkins}天)`;
170
+ });
171
+ const title = ctxType === "group" ? "本群积分排行" : "全局积分排行";
172
+ const userId = String(message.$sender?.id || "");
173
+ const myRank = sorted.findIndex((u) => u.user_id === userId);
174
+ const footer = myRank >= 0 ? `\n你的排名: 第${myRank + 1}名` : "";
175
+ return `${title}\n${lines.join("\n")}${footer}`;
176
+ }),
177
+ );
178
+
179
+ if (!_db) {
180
+ const db = getDatabase(plugin);
181
+ if (db) _db = db;
182
+ }
183
+
184
+ logger.info(
185
+ formatCompact({
186
+ op: "checkin_load",
187
+ base_min: cfg.basePointsMin,
188
+ base_max: cfg.basePointsMax,
189
+ }),
190
+ );
191
+ }
package/src/config.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { Schema } from "zhin.js";
2
+
3
+ /** 扁平 groupSuite 配置(单层字段,默认值覆盖多数场景) */
4
+ export const groupSuiteSchema = Schema.object({
5
+ welcome: Schema.string()
6
+ .default("欢迎新成员加入本群!")
7
+ .description("入群欢迎语"),
8
+ recallNotify: Schema.boolean()
9
+ .default(true)
10
+ .description("群消息撤回时是否提示"),
11
+ keywordReply: Schema.boolean()
12
+ .default(false)
13
+ .description("内置关键词回复(默认关闭,用 teach 问答)"),
14
+ noticeAdapters: Schema.list(Schema.string())
15
+ .default(["icqq"])
16
+ .description("处理 notice 的适配器(欢迎/撤回)"),
17
+
18
+ basePointsMin: Schema.number().default(10).min(1).max(100),
19
+ basePointsMax: Schema.number().default(30).min(1).max(200),
20
+ streakBonus: Schema.number().default(5).min(0).max(100),
21
+ streakCap: Schema.number().default(50).min(0).max(500),
22
+
23
+ rankSize: Schema.number()
24
+ .default(10)
25
+ .min(3)
26
+ .max(50)
27
+ .description("签到排行与消息统计排行显示人数"),
28
+ statsRetentionDays: Schema.number().default(90).min(7).max(365),
29
+
30
+ analysisDays: Schema.number().default(1).min(1).max(30),
31
+ autoAnalysisEnabled: Schema.boolean().default(false),
32
+ autoAnalysisCron: Schema.string().default("0 9 * * *"),
33
+ analysisGroups: Schema.list(Schema.string())
34
+ .default([])
35
+ .description("启用群日报的群 ID 白名单(空=不限制)"),
36
+ analysisGroupsBlock: Schema.list(Schema.string())
37
+ .default([])
38
+ .description("禁用群日报的群 ID"),
39
+ analysisFormat: Schema.string()
40
+ .default("text")
41
+ .description("日报格式 text | image"),
42
+ analysisMaxMessages: Schema.number().default(500).min(100).max(5000),
43
+
44
+ teachMaxPerGroup: Schema.number().default(200).min(1).max(1000),
45
+ teachCooldownMs: Schema.number().default(3000).min(0).max(60000),
46
+ teachAllowRegex: Schema.boolean().default(true),
47
+ teachPageSize: Schema.number().default(10).min(5).max(50),
48
+ });
49
+
50
+ export type GroupSuiteConfig = {
51
+ welcome: string;
52
+ recallNotify: boolean;
53
+ keywordReply: boolean;
54
+ noticeAdapters: string[];
55
+ basePointsMin: number;
56
+ basePointsMax: number;
57
+ streakBonus: number;
58
+ streakCap: number;
59
+ rankSize: number;
60
+ statsRetentionDays: number;
61
+ analysisDays: number;
62
+ autoAnalysisEnabled: boolean;
63
+ autoAnalysisCron: string;
64
+ analysisGroups: string[];
65
+ analysisGroupsBlock: string[];
66
+ analysisFormat: string;
67
+ analysisMaxMessages: number;
68
+ teachMaxPerGroup: number;
69
+ teachCooldownMs: number;
70
+ teachAllowRegex: boolean;
71
+ teachPageSize: number;
72
+ };