@zhin.js/plugin-group-daily-analysis 0.0.2

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.
@@ -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/index.ts ADDED
@@ -0,0 +1,438 @@
1
+ /**
2
+ * @zhin.js/plugin-group-daily-analysis
3
+ *
4
+ * 群日常分析插件:基于 zhin 内置收件箱(unified_inbox_message)做群聊统计与可选 LLM 分析。
5
+ * 功能灵感与参考:https://github.com/SXP-Simon/astrbot_plugin_qq_group_daily_analysis
6
+ * 另见:https://github.com/LSTM-Kirigaya/openmcp-tutorial/tree/main/qq-group-summary
7
+ *
8
+ * 依赖:主配置中启用 inbox.enabled 与 database。
9
+ * 命令:/群分析 [天数]、/分析设置 enable|disable|status
10
+ */
11
+ import { usePlugin, MessageCommand, Schema, Cron,segment } from "zhin.js";
12
+ import type { InboxMessageRow } from "./analysis.js";
13
+ import type {} from "@zhin.js/plugin-html-renderer";
14
+ import {
15
+ computeBasicStats,
16
+ formatTextReport,
17
+ appendLLMReport,
18
+ parseLLMResponse,
19
+ extractText,
20
+ type AnalysisResult,
21
+ } from "./analysis.js";
22
+
23
+ const plugin = usePlugin();
24
+ const {
25
+ logger,
26
+ root,
27
+ addCommand,
28
+ useContext,
29
+ addCron,
30
+ onDispose,
31
+ declareConfig,
32
+ } = plugin;
33
+
34
+ const INBOX_TABLE = "unified_inbox_message";
35
+ const SETTINGS_TABLE = "group_daily_analysis_settings";
36
+
37
+ // ─── 配置 ─────────────────────────────────────────────────────────────────────
38
+
39
+ const config = declareConfig(
40
+ "group-daily-analysis",
41
+ Schema.object({
42
+ analysisDays: Schema.number()
43
+ .default(1)
44
+ .min(1)
45
+ .max(30)
46
+ .description("默认分析最近天数"),
47
+ autoAnalysisEnabled: Schema.boolean()
48
+ .default(false)
49
+ .description("是否启用定时自动分析"),
50
+ autoAnalysisCron: Schema.string()
51
+ .default("0 9 * * *")
52
+ .description("每日分析 Cron(默认每天 9 点)"),
53
+ enabledGroups: Schema.list(Schema.string())
54
+ .default([])
55
+ .description("启用分析的群 ID 白名单(空表示不限制)"),
56
+ disabledGroups: Schema.list(Schema.string())
57
+ .default([])
58
+ .description("禁用分析的群 ID 黑名单"),
59
+ outputFormat: Schema.string()
60
+ .default("text")
61
+ .description("报告输出格式:text 或 image(需 html-renderer)"),
62
+ maxMessagesPerAnalysis: Schema.number()
63
+ .default(500)
64
+ .min(100)
65
+ .max(5000)
66
+ .description("单次分析使用的最大消息条数"),
67
+ })
68
+ );
69
+
70
+ // ─── 数据库与收件箱 ───────────────────────────────────────────────────────────
71
+
72
+ let _db: any = null;
73
+ let _settingsModel: any = null;
74
+
75
+ function getInboxModel(): any {
76
+ if (!_db) {
77
+ const database = root.inject("database" as any) as any;
78
+ if (database) _db = database;
79
+ }
80
+ return _db?.models?.get(INBOX_TABLE) ?? null;
81
+ }
82
+
83
+ function getSettingsModel(): any {
84
+ return _db?.models?.get(SETTINGS_TABLE) ?? null;
85
+ }
86
+
87
+ function getChannelFromMessage(message: any): { channelId: string; channelType: string; adapter?: string; botId?: string } | null {
88
+ const ch = message?.$channel;
89
+ if (!ch?.id) return null;
90
+ return {
91
+ channelId: String(ch.id),
92
+ channelType: String(ch.type || "private"),
93
+ adapter: message?.$adapter,
94
+ botId: message?.$bot,
95
+ };
96
+ }
97
+
98
+ useContext("database", (db: any) => {
99
+ _db = db;
100
+ // 收件箱表由主包在 inbox.enabled 时注册,此处仅只读使用
101
+ const inboxModel = db.models?.get(INBOX_TABLE);
102
+ if (!inboxModel) {
103
+ logger.warn("[group-daily-analysis] 未检测到收件箱表,请在主配置中启用 inbox.enabled 与 database");
104
+ }
105
+ // 本插件仅新增:群分析开关表(用于 /分析设置)
106
+ if (typeof db.define === "function") {
107
+ db.define(SETTINGS_TABLE, {
108
+ id: { type: "integer", primary: true, autoIncrement: true },
109
+ channel_id: { type: "text", nullable: false },
110
+ channel_type: { type: "text", nullable: false },
111
+ adapter: { type: "text", default: "" },
112
+ bot_id: { type: "text", default: "" },
113
+ enabled: { type: "integer", default: 1 },
114
+ updated_at: { type: "text", default: "" },
115
+ });
116
+ logger.info("[group-daily-analysis] 分析设置表已注册");
117
+ }
118
+ });
119
+
120
+ // ─── 群组是否参与分析(白名单/黑名单 + 分析设置)──────────────────────────────
121
+
122
+ function isGroupInList(groupId: string, list: string[]): boolean {
123
+ return list.some((id) => id === groupId || id === String(groupId));
124
+ }
125
+
126
+ async function isAnalysisEnabledForChannel(
127
+ channelId: string,
128
+ channelType: string,
129
+ adapter: string
130
+ ): Promise<boolean> {
131
+ if (channelType !== "group") return false;
132
+ if (config.disabledGroups.length > 0 && isGroupInList(channelId, config.disabledGroups)) return false;
133
+ if (config.enabledGroups.length > 0 && !isGroupInList(channelId, config.enabledGroups)) return false;
134
+ const Settings = getSettingsModel();
135
+ if (!Settings) return true;
136
+ try {
137
+ const rows: any[] = await Settings.select().where({
138
+ channel_id: channelId,
139
+ channel_type: channelType,
140
+ adapter: adapter || "",
141
+ });
142
+ if (rows.length === 0) return true;
143
+ return (rows[0].enabled ?? 1) === 1;
144
+ } catch {
145
+ return true;
146
+ }
147
+ }
148
+
149
+ async function setAnalysisEnabledForChannel(
150
+ channelId: string,
151
+ channelType: string,
152
+ adapter: string,
153
+ botId: string,
154
+ enabled: boolean
155
+ ): Promise<void> {
156
+ const Settings = getSettingsModel();
157
+ if (!Settings) return;
158
+ const ts = new Date().toISOString();
159
+ const rows: any[] = await Settings.select().where({
160
+ channel_id: channelId,
161
+ channel_type: channelType,
162
+ adapter: adapter || "",
163
+ });
164
+ if (rows.length > 0) {
165
+ await Settings.update({
166
+ enabled: enabled ? 1 : 0,
167
+ updated_at: ts,
168
+ bot_id: botId || rows[0].bot_id || "",
169
+ }).where({ id: rows[0].id });
170
+ } else {
171
+ await Settings.insert({
172
+ channel_id: channelId,
173
+ channel_type: channelType,
174
+ adapter: adapter || "",
175
+ bot_id: botId || "",
176
+ enabled: enabled ? 1 : 0,
177
+ updated_at: ts,
178
+ });
179
+ }
180
+ }
181
+
182
+ // ─── 从收件箱查询消息并执行分析 ─────────────────────────────────────────────────
183
+
184
+ function getTimeRangeDays(days: number): { start: number; end: number; startStr: string; endStr: string } {
185
+ const end = Date.now();
186
+ const start = end - days * 24 * 60 * 60 * 1000;
187
+ const dStart = new Date(start);
188
+ const dEnd = new Date(end);
189
+ const fmt = (d: Date) =>
190
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
191
+ return { start, end, startStr: fmt(dStart), endStr: fmt(dEnd) };
192
+ }
193
+
194
+ async function queryInboxMessages(
195
+ channelId: string,
196
+ adapter: string,
197
+ botId: string,
198
+ startTs: number,
199
+ endTs: number,
200
+ limit: number
201
+ ): Promise<InboxMessageRow[]> {
202
+ const Inbox = getInboxModel();
203
+ if (!Inbox) return [];
204
+ try {
205
+ let rows: any[];
206
+ try {
207
+ rows = await Inbox.select()
208
+ .where({
209
+ channel_id: channelId,
210
+ channel_type: "group",
211
+ adapter: adapter || "",
212
+ bot_id: botId || "",
213
+ created_at: { $gte: startTs, $lte: endTs },
214
+ })
215
+ .orderBy("created_at", "ASC")
216
+ .limit(limit);
217
+ } catch {
218
+ rows = await Inbox.select()
219
+ .where({ channel_id: channelId, channel_type: "group", adapter: adapter || "", bot_id: botId || "" });
220
+ rows = (rows || [])
221
+ .filter((r: any) => r.created_at >= startTs && r.created_at <= endTs)
222
+ .sort((a: any, b: any) => a.created_at - b.created_at)
223
+ .slice(0, limit);
224
+ }
225
+ return rows as InboxMessageRow[];
226
+ } catch (e) {
227
+ logger.warn("[group-daily-analysis] 查询收件箱失败", (e as Error)?.message);
228
+ return [];
229
+ }
230
+ }
231
+
232
+ async function runAnalysis(
233
+ channelId: string,
234
+ channelName: string,
235
+ adapter: string,
236
+ botId: string,
237
+ days: number
238
+ ): Promise<AnalysisResult | string> {
239
+ const Inbox = getInboxModel();
240
+ if (!Inbox) {
241
+ return "请先在配置中启用 inbox.enabled 并配置 database,以使用群日常分析。";
242
+ }
243
+ const { start, end, startStr, endStr } = getTimeRangeDays(days);
244
+ const rows = await queryInboxMessages(
245
+ channelId,
246
+ adapter,
247
+ botId,
248
+ start,
249
+ end,
250
+ config.maxMessagesPerAnalysis
251
+ );
252
+ if (rows.length === 0) {
253
+ return `在 ${startStr} 至 ${endStr} 区间内没有找到本群消息记录,请确认收件箱已启用并有过群消息。`;
254
+ }
255
+ const stats = computeBasicStats(rows);
256
+ let textReport = formatTextReport(stats, {
257
+ channelName: channelName || undefined,
258
+ days,
259
+ startDate: startStr,
260
+ endDate: endStr,
261
+ });
262
+
263
+ // 可选:LLM 话题/金句/用户画像(灵感来自 astrbot_plugin_qq_group_daily_analysis)
264
+ const ai = root.inject("ai" as any) as { ask?: (q: string, opts?: { systemPrompt?: string }) => Promise<string> } | undefined;
265
+ if (ai?.ask) {
266
+ try {
267
+ const sampleSize = Math.min(rows.length, 150);
268
+ const sample = rows.slice(-sampleSize);
269
+ const conversation = sample
270
+ .map((r) => `[${r.sender_name || r.sender_id}]: ${extractText(r).slice(0, 200)}`)
271
+ .join("\n");
272
+ const systemPrompt = `你是一个群聊分析助手。根据下面的群聊消息摘要,提取并仅返回一个 JSON 对象,包含以下三个数组(均为中文):
273
+ - topics: 数组,每项 { "topic": "话题关键词", "summary": "简短说明" }
274
+ - quotes: 数组,每项 { "content": "金句原文", "sender": "发言人", "reason": "入选理由" },最多 5 条
275
+ - user_titles: 数组,每项 { "name": "昵称", "user_id": "id", "title": "称号/画像", "reason": "理由" },最多 8 人
276
+ 不要输出除 JSON 以外的内容。`;
277
+ const answer = await ai.ask(
278
+ `请分析以下群聊片段并返回 JSON:\n\n${conversation.slice(0, 6000)}`,
279
+ { systemPrompt }
280
+ );
281
+ if (answer && answer.trim()) {
282
+ const llm = parseLLMResponse(answer);
283
+ if (llm.topics?.length || llm.quotes?.length || llm.userTitles?.length) {
284
+ textReport = appendLLMReport(textReport, llm);
285
+ }
286
+ }
287
+ } catch (e) {
288
+ logger.debug("[group-daily-analysis] LLM 分析跳过", (e as Error)?.message);
289
+ }
290
+ }
291
+
292
+ return { stats, textReport };
293
+ }
294
+
295
+ // ─── 命令:群分析 ─────────────────────────────────────────────────────────────
296
+
297
+ addCommand(
298
+ new MessageCommand("群分析 [天数:number]")
299
+ .desc("群日常分析", "分析本群近期消息统计(依赖收件箱)")
300
+ .action(async (message: any, result: any) => {
301
+ const ch = getChannelFromMessage(message);
302
+ if (!ch || ch.channelType !== "group") {
303
+ return "请在群聊中使用本命令。";
304
+ }
305
+ const days = Math.min(30, Math.max(1, Number(result.params?.天数) || config.analysisDays));
306
+ const channelName = (message?.$channel as { name?: string })?.name || "";
307
+ const report = await runAnalysis(
308
+ ch.channelId,
309
+ channelName,
310
+ ch.adapter || "",
311
+ ch.botId || "",
312
+ days
313
+ );
314
+ if (typeof report === "string") return report;
315
+ if (config.outputFormat === "image") {
316
+ const renderer = root.inject("html-renderer")
317
+ if (renderer) {
318
+ try {
319
+ const escaped = report.textReport
320
+ .replace(/&/g, "&amp;")
321
+ .replace(/</g, "&lt;")
322
+ .replace(/>/g, "&gt;")
323
+ .replace(/\n/g, "<br/>");
324
+ const html = `<div style="padding:20px;font-size:14px;line-height:1.5;white-space:pre-wrap;background:#fafafa;border-radius:8px;">${escaped}</div>`;
325
+ const resultImg = await renderer.render(html);
326
+ const base64 = (resultImg.data as Buffer).toString("base64");
327
+ const dataUrl = `base64://${base64}`;
328
+ return [segment("image", { url: dataUrl,name: "group-daily-analysis.png" })];
329
+ } catch (e) {
330
+ logger.debug("[group-daily-analysis] 图片渲染失败,回退文本", (e as Error)?.message);
331
+ }
332
+ }
333
+ }
334
+ return report.textReport;
335
+ })
336
+ );
337
+
338
+ // ─── 命令:分析设置 ───────────────────────────────────────────────────────────
339
+
340
+ addCommand(
341
+ new MessageCommand("分析设置 [操作:text]")
342
+ .desc("分析设置", "enable=启用本群分析 / disable=禁用 / status=查看状态")
343
+ .action(async (message: any, result: any) => {
344
+ const ch = getChannelFromMessage(message);
345
+ if (!ch || ch.channelType !== "group") {
346
+ return "请在群聊中使用本命令。";
347
+ }
348
+ const op = (result.params?.操作 || "status").trim().toLowerCase();
349
+ const adapter = ch.adapter || "";
350
+ const botId = ch.botId || "";
351
+ if (op === "enable") {
352
+ await setAnalysisEnabledForChannel(ch.channelId, ch.channelType, adapter, botId, true);
353
+ return "已为本群启用日常分析。";
354
+ }
355
+ if (op === "disable") {
356
+ await setAnalysisEnabledForChannel(ch.channelId, ch.channelType, adapter, botId, false);
357
+ return "已为本群关闭日常分析。";
358
+ }
359
+ if (op === "status") {
360
+ const enabled = await isAnalysisEnabledForChannel(ch.channelId, ch.channelType, adapter);
361
+ return `本群日常分析:${enabled ? "已启用" : "已关闭"}`;
362
+ }
363
+ return "用法:分析设置 enable | disable | status";
364
+ })
365
+ );
366
+
367
+ // ─── 定时任务(可选)───────────────────────────────────────────────────────────
368
+
369
+ let cronDispose: (() => void) | null = null;
370
+
371
+ useContext("database", () => {
372
+ if (!config.autoAnalysisEnabled || cronDispose) return;
373
+ if (typeof addCron !== "function") return;
374
+ try {
375
+ cronDispose = addCron(
376
+ new Cron(config.autoAnalysisCron, async () => {
377
+ const inject = root.inject?.bind(root) as (key: string) => any;
378
+ if (typeof inject !== "function") return;
379
+ const targets: { channelId: string; adapter: string; botId: string }[] = [];
380
+ const Settings = getSettingsModel();
381
+ if (Settings) {
382
+ const rows: any[] = await Settings.select().where({ channel_type: "group", enabled: 1 });
383
+ for (const r of rows) {
384
+ if (r.channel_id && r.adapter) targets.push({
385
+ channelId: String(r.channel_id),
386
+ adapter: String(r.adapter),
387
+ botId: String(r.bot_id || ""),
388
+ });
389
+ }
390
+ }
391
+ for (const gid of config.enabledGroups) {
392
+ if (targets.some((t) => t.channelId === gid)) continue;
393
+ const adapters = (root as any).adapters as string[] | undefined;
394
+ const adapterName = Array.isArray(adapters) && adapters[0] ? adapters[0] : "";
395
+ if (adapterName) targets.push({ channelId: String(gid), adapter: adapterName, botId: "" });
396
+ }
397
+ for (const t of targets) {
398
+ if (config.disabledGroups.length && isGroupInList(t.channelId, config.disabledGroups)) continue;
399
+ try {
400
+ const report = await runAnalysis(t.channelId, "", t.adapter, t.botId, config.analysisDays);
401
+ if (typeof report === "string") continue;
402
+ const adapter = inject(t.adapter);
403
+ if (adapter?.sendMessage) {
404
+ let botId = t.botId;
405
+ if (!botId && adapter.bots?.size) {
406
+ const first = adapter.bots.values().next().value;
407
+ botId = first?.$id ?? first?.selfId ?? "";
408
+ }
409
+ await adapter.sendMessage({
410
+ context: t.adapter,
411
+ bot: botId,
412
+ type: "group",
413
+ id: t.channelId,
414
+ content: report.textReport,
415
+ }).catch(() => {});
416
+ }
417
+ } catch (e) {
418
+ logger.warn("[group-daily-analysis] 定时分析发送失败", t.channelId, (e as Error)?.message);
419
+ }
420
+ }
421
+ })
422
+ );
423
+ logger.info("[group-daily-analysis] 定时分析已注册: " + config.autoAnalysisCron);
424
+ } catch (e) {
425
+ logger.warn("[group-daily-analysis] 注册定时任务失败", (e as Error)?.message);
426
+ }
427
+ });
428
+
429
+ onDispose(() => {
430
+ if (cronDispose) {
431
+ cronDispose();
432
+ cronDispose = null;
433
+ }
434
+ });
435
+
436
+ logger.info(
437
+ `[group-daily-analysis] 已加载 (分析天数=${config.analysisDays}, 定时=${config.autoAnalysisEnabled ? config.autoAnalysisCron : "关"})`
438
+ );