koishi-plugin-chat-analyse 1.3.0 → 1.3.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.
package/lib/Stat.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { Context, Command } from 'koishi';
2
+ import { Renderer } from './Renderer';
3
+ import { Config } from './index';
4
+ /**
5
+ * @class Stat
6
+ * @description 提供统一的统计查询服务。负责注册查询命令,从数据库获取数据,并调用渲染器生成图表。
7
+ */
8
+ export declare class Stat {
9
+ private ctx;
10
+ private config;
11
+ renderer: Renderer;
12
+ /**
13
+ * @param ctx - Koishi 的插件上下文。
14
+ * @param config - 插件的配置对象。
15
+ */
16
+ constructor(ctx: Context, config: Config);
17
+ /**
18
+ * @private @method parseScope
19
+ * @description 根据选项解析查询范围,返回 uids 和范围描述
20
+ */
21
+ private parseScope;
22
+ /**
23
+ * @public @method registerCommands
24
+ * @description 根据配置,动态地将子命令注册到主命令下。
25
+ * @param cmd - 主命令实例。
26
+ */
27
+ registerCommands(cmd: Command): void;
28
+ }
package/lib/index.js CHANGED
@@ -1527,7 +1527,7 @@ var Renderer = class {
1527
1527
  border-bottom: 1px solid var(--border-color);
1528
1528
  }
1529
1529
  .title-text {
1530
- font-size: 18px; font-weight: 600; color: var(--header-color);
1530
+ font-size: 16px; font-weight: 600; color: var(--header-color);
1531
1531
  margin: 0; text-align: center;
1532
1532
  }
1533
1533
  .stat-chip, .time-label {
@@ -1631,7 +1631,7 @@ var Renderer = class {
1631
1631
  .main-table { border-collapse: collapse; width: 100%; }
1632
1632
  .main-table th, .main-table td { padding: 9px 16px; vertical-align: middle; text-align: left; }
1633
1633
  .main-table thead { border-bottom: 1px solid var(--border-color); }
1634
- .main-table th { font-size: 12px; font-weight: 500; color: var(--sub-text-color); text-transform: uppercase; }
1634
+ .main-table th { font-size: 12px; font-weight: 500; color: var(--sub-text-color); text-transform: uppercase; white-space: nowrap; }
1635
1635
  .main-table td { font-size: 14px; color: var(--text-color); }
1636
1636
  .main-table tbody tr:nth-child(even) { background-color: var(--stripe-bg); }
1637
1637
  .rank-cell, .count-cell, .date-cell, .percent-cell { text-align: right; white-space: nowrap; width: 1%; font-variant-numeric: tabular-nums; }
@@ -1808,141 +1808,197 @@ var Stat = class {
1808
1808
  __name(this, "Stat");
1809
1809
  }
1810
1810
  renderer;
1811
+ /**
1812
+ * @private @method parseScope
1813
+ * @description 根据选项解析查询范围,返回 uids 和范围描述
1814
+ */
1815
+ async parseScope(session, options) {
1816
+ const scopeDesc = { guildId: void 0, userId: void 0 };
1817
+ const query = {};
1818
+ if (options.all) return { uids: void 0, scopeDesc };
1819
+ if (options.user) scopeDesc.userId = import_koishi3.h.select(options.user, "at")[0]?.attrs.id ?? options.user.trim();
1820
+ if (options.guild) scopeDesc.guildId = options.guild;
1821
+ if (!scopeDesc.guildId && !scopeDesc.userId) scopeDesc.guildId = session.guildId;
1822
+ if (!scopeDesc.guildId && !scopeDesc.userId) return { error: "请指定查询范围", scopeDesc };
1823
+ if (scopeDesc.guildId) query.channelId = scopeDesc.guildId;
1824
+ if (scopeDesc.userId) query.userId = scopeDesc.userId;
1825
+ const users = await this.ctx.database.get("analyse_user", query, ["uid"]);
1826
+ if (users.length === 0) return { error: "暂无统计数据", scopeDesc };
1827
+ return { uids: users.map((u) => u.uid), scopeDesc };
1828
+ }
1811
1829
  /**
1812
1830
  * @public @method registerCommands
1813
1831
  * @description 根据配置,动态地将子命令注册到主命令下。
1814
1832
  * @param cmd - 主命令实例。
1815
1833
  */
1816
1834
  registerCommands(cmd) {
1817
- const createHandler = /* @__PURE__ */ __name((handler) => {
1818
- return async ({ session, options }) => {
1819
- const scope = await parseQueryScope(this.ctx, session, options);
1820
- if (scope.error) return scope.error;
1821
- try {
1822
- const result = await handler(scope, options);
1823
- if (typeof result === "string") return result;
1824
- for await (const buffer of result) await session.send(import_koishi3.h.image(buffer, "image/png"));
1825
- } catch (error) {
1826
- this.ctx.logger.error("图片渲染失败:", error);
1827
- return "图片渲染失败";
1828
- }
1829
- };
1830
- }, "createHandler");
1835
+ const handleAction = /* @__PURE__ */ __name(async (session, promise) => {
1836
+ try {
1837
+ const result = await promise;
1838
+ if (typeof result === "string") return result;
1839
+ for await (const buffer of result) await session.send(import_koishi3.h.image(buffer, "image/png"));
1840
+ } catch (error) {
1841
+ this.ctx.logger.error("图片渲染失败:", error);
1842
+ return "图片渲染失败";
1843
+ }
1844
+ }, "handleAction");
1831
1845
  if (this.config.enableCmdStat) {
1832
- cmd.subcommand("cmdstat", "命令统计").usage("查询命令统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("separate", "-s 分离展示").option("all", "-a 全局").action(createHandler(async (scope, options) => {
1833
- const stats = await this.ctx.database.select("analyse_cmd").where({ uid: { $in: scope.uids } }).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
1846
+ cmd.subcommand("cmdstat", "命令统计").usage("查询命令统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("separate", "-p 分离子命令").option("sortByTime", "-s 以时间排序").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
1847
+ const scope = await this.parseScope(session, options);
1848
+ if (scope.error) return scope.error;
1849
+ const query = scope.uids ? { uid: { $in: scope.uids } } : {};
1850
+ const stats = await this.ctx.database.select("analyse_cmd").where(query).groupBy("command", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).execute();
1834
1851
  if (stats.length === 0) return "暂无统计数据";
1835
1852
  let processedStats;
1836
1853
  if (options.separate) {
1837
1854
  processedStats = stats;
1838
1855
  } else {
1839
- const mergedStatsMap = /* @__PURE__ */ new Map();
1856
+ const merged = /* @__PURE__ */ new Map();
1840
1857
  for (const stat of stats) {
1841
- const mainCommand = stat.command.split(".")[0];
1842
- const existing = mergedStatsMap.get(mainCommand) || { count: 0, lastUsed: /* @__PURE__ */ new Date(0) };
1858
+ const mainCmd = stat.command.split(".")[0];
1859
+ const existing = merged.get(mainCmd) || { count: 0, lastUsed: /* @__PURE__ */ new Date(0) };
1843
1860
  existing.count += stat.count;
1844
1861
  if (stat.lastUsed > existing.lastUsed) existing.lastUsed = stat.lastUsed;
1845
- mergedStatsMap.set(mainCommand, existing);
1862
+ merged.set(mainCmd, existing);
1846
1863
  }
1847
- processedStats = Array.from(mergedStatsMap.entries()).map(([command, data]) => ({
1848
- command,
1849
- count: data.count,
1850
- lastUsed: data.lastUsed
1851
- })).sort((a, b) => b.count - a.count);
1864
+ processedStats = Array.from(merged.entries()).map(([command, data]) => ({ ...data, command }));
1852
1865
  }
1853
- const total = processedStats.reduce((sum, record) => sum + record.count, 0);
1866
+ if (options.sortByTime) {
1867
+ processedStats.sort((a, b) => b.lastUsed.getTime() - a.lastUsed.getTime());
1868
+ } else {
1869
+ processedStats.sort((a, b) => b.count - a.count);
1870
+ }
1871
+ const total = processedStats.reduce((sum, r) => sum + r.count, 0);
1854
1872
  const list = processedStats.map((item) => [item.command, item.count, item.lastUsed]);
1855
1873
  const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "命令" });
1856
1874
  return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["命令", "次数", "最后使用"]);
1857
- }));
1875
+ })()));
1858
1876
  }
1859
1877
  if (this.config.enableMsgStat) {
1860
- cmd.subcommand("msgstat", "发言统计").usage("查询发言统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("all", "-a 全局").action(createHandler(async (scope, options) => {
1861
- const { type } = options;
1862
- const query = { uid: { $in: scope.uids } };
1863
- if (type) query.type = type;
1864
- const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
1865
- const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
1866
- const stats = await this.ctx.database.select("analyse_msg").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).orderBy("count", "desc").execute();
1878
+ cmd.subcommand("msgstat", "发言统计").usage("查询发言统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("sortByTime", "-s 以时间排序").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
1879
+ const scope = await this.parseScope(session, options);
1880
+ if (scope.error) return scope.error;
1881
+ const query = scope.uids ? { uid: { $in: scope.uids } } : {};
1882
+ if (options.type) query.type = options.type;
1883
+ const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "发言", subtype: options.type });
1884
+ const applySort = /* @__PURE__ */ __name((stats2) => {
1885
+ if (options.sortByTime) {
1886
+ stats2.sort((a, b) => b.lastUsed.getTime() - a.lastUsed.getTime());
1887
+ } else {
1888
+ stats2.sort((a, b) => b.count - a.count);
1889
+ }
1890
+ }, "applySort");
1891
+ if (options.user && options.guild) {
1892
+ const stats2 = await this.ctx.database.select("analyse_msg").where(query).groupBy("type", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).execute();
1893
+ if (stats2.length === 0) return "暂无统计数据";
1894
+ applySort(stats2);
1895
+ const total2 = stats2.reduce((sum, r) => sum + r.count, 0);
1896
+ const list2 = stats2.map((item) => [item.type, item.count, item.lastUsed]);
1897
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total: total2, list: list2 }, ["类型", "条数", "最后发言"]);
1898
+ }
1899
+ if (options.user) {
1900
+ const userRecords = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } });
1901
+ const uidToChannelMap = new Map(userRecords.map((u) => [u.uid, u.channelName || u.channelId]));
1902
+ const stats2 = await this.ctx.database.select("analyse_msg").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).execute();
1903
+ if (stats2.length === 0) return "暂无统计数据";
1904
+ applySort(stats2);
1905
+ const total2 = stats2.reduce((sum, r) => sum + r.count, 0);
1906
+ const list2 = stats2.map((item) => [uidToChannelMap.get(item.uid) || `未知群组`, item.count, item.lastUsed]);
1907
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total: total2, list: list2 }, ["群组", "条数", "最后发言"]);
1908
+ }
1909
+ const stats = await this.ctx.database.select("analyse_msg").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count"), lastUsed: /* @__PURE__ */ __name((row) => import_koishi3.$.max(row.timestamp), "lastUsed") }).execute();
1867
1910
  if (stats.length === 0) return "暂无统计数据";
1911
+ applySort(stats);
1912
+ const allUids = stats.map((s) => s.uid);
1913
+ const userNameMap = /* @__PURE__ */ new Map();
1914
+ const BATCH_SIZE2 = 4096;
1915
+ for (let i = 0; i < allUids.length; i += BATCH_SIZE2) {
1916
+ const batchUids = allUids.slice(i, i + BATCH_SIZE2);
1917
+ const users = await this.ctx.database.get("analyse_user", { uid: { $in: batchUids } }, ["uid", "userName"]);
1918
+ for (const user of users) {
1919
+ userNameMap.set(user.uid, user.userName);
1920
+ }
1921
+ }
1868
1922
  const total = stats.reduce((sum, r) => sum + r.count, 0);
1869
1923
  const list = stats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count, item.lastUsed]);
1870
- const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "发言", subtype: type });
1871
- const headers = type ? ["用户", "条数", "最后发言"] : ["用户", "总计发言", "最后发言"];
1872
- return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, headers);
1873
- }));
1924
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "条数", "最后发言"]);
1925
+ })()));
1874
1926
  }
1875
1927
  if (this.config.enableRankStat) {
1876
- cmd.subcommand("rankstat", "发言排行").usage("查询发言排行,可指定查询范围,默认当前群组。").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("hours", "-o <hours:number> 指定时长", { fallback: 24 }).option("all", "-a 全局").action(createHandler(async (scope, options) => {
1877
- const { hours, type } = options;
1878
- const since = new Date(Date.now() - hours * import_koishi3.Time.hour);
1879
- const query = { uid: { $in: scope.uids }, timestamp: { $gte: since } };
1880
- if (type) query.type = type;
1881
- const rankStats = await this.ctx.database.select("analyse_rank").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
1882
- if (rankStats.length === 0) return "暂无统计数据";
1883
- const users = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } }, ["uid", "userName"]);
1884
- const userNameMap = new Map(users.map((u) => [u.uid, u.userName]));
1885
- const total = rankStats.reduce((sum, record) => sum + record.count, 0);
1886
- const list = rankStats.map((item) => [userNameMap.get(item.uid) || `UID ${item.uid}`, item.count]);
1887
- const listWithPercentage = list.map((row) => [...row, total > 0 ? `${(row[1] / total * 100).toFixed(2)}%` : "0.00%"]);
1888
- const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "发言排行", timeRange: hours, subtype: type });
1889
- return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list: listWithPercentage }, ["用户", "总计发言", "占比"]);
1890
- }));
1928
+ cmd.subcommand("rankstat", "发言排行").usage("查询发言排行,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("type", "-t <type:string> 指定类型").option("duration", "-n <hours:number> 指定时长", { fallback: 24 }).option("offset", "-o <hours:number> 指定偏移").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
1929
+ const scope = await this.parseScope(session, options);
1930
+ if (scope.error) return scope.error;
1931
+ const until = new Date(Date.now() - options.offset * import_koishi3.Time.hour);
1932
+ const since = new Date(until.getTime() - options.duration * import_koishi3.Time.hour);
1933
+ const query = { timestamp: { $gte: since, $lt: until } };
1934
+ if (scope.uids) query.uid = { $in: scope.uids };
1935
+ if (options.type) query.type = options.type;
1936
+ const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "发言排行", timeRange: options.duration, subtype: options.type });
1937
+ if (options.user && options.guild) {
1938
+ const stats2 = await this.ctx.database.select("analyse_rank").where(query).groupBy("type", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
1939
+ if (stats2.length === 0) return "暂无统计数据";
1940
+ const total2 = stats2.reduce((sum, r) => sum + r.count, 0);
1941
+ const list2 = stats2.map((r) => [r.type, r.count, total2 > 0 ? `${(r.count / total2 * 100).toFixed(2)}%` : "0.00%"]);
1942
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total: total2, list: list2 }, ["类型", "条数", "占比"]);
1943
+ }
1944
+ if (options.user) {
1945
+ const userRecords = await this.ctx.database.get("analyse_user", { uid: { $in: scope.uids } });
1946
+ const uidToChannelMap = new Map(userRecords.map((u) => [u.uid, u.channelName || u.channelId]));
1947
+ const stats2 = await this.ctx.database.select("analyse_rank").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
1948
+ if (stats2.length === 0) return "暂无统计数据";
1949
+ const total2 = stats2.reduce((sum, r) => sum + r.count, 0);
1950
+ const list2 = stats2.map((r) => [uidToChannelMap.get(r.uid) || "未知群组", r.count, total2 > 0 ? `${(r.count / total2 * 100).toFixed(2)}%` : "0.00%"]);
1951
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total: total2, list: list2 }, ["群组", "条数", "占比"]);
1952
+ }
1953
+ const stats = await this.ctx.database.select("analyse_rank").where(query).groupBy("uid", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).orderBy("count", "desc").execute();
1954
+ if (stats.length === 0) return "暂无统计数据";
1955
+ const allUids = stats.map((s) => s.uid);
1956
+ const userNameMap = /* @__PURE__ */ new Map();
1957
+ const BATCH_SIZE2 = 4096;
1958
+ for (let i = 0; i < allUids.length; i += BATCH_SIZE2) {
1959
+ const batchUids = allUids.slice(i, i + BATCH_SIZE2);
1960
+ const users = await this.ctx.database.get("analyse_user", { uid: { $in: batchUids } }, ["uid", "userName"]);
1961
+ for (const user of users) {
1962
+ userNameMap.set(user.uid, user.userName);
1963
+ }
1964
+ }
1965
+ const total = stats.reduce((sum, r) => sum + r.count, 0);
1966
+ const list = stats.map((r) => [userNameMap.get(r.uid) || `UID ${r.uid}`, r.count, total > 0 ? `${(r.count / total * 100).toFixed(2)}%` : "0.00%"]);
1967
+ return this.renderer.renderList({ title, time: /* @__PURE__ */ new Date(), total, list }, ["用户", "条数", "占比"]);
1968
+ })()));
1891
1969
  }
1892
1970
  if (this.config.enableActivity) {
1893
- cmd.subcommand("activity", "活跃统计").usage("查询活跃统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("hours", "-t <hours:number> 指定偏移时长").option("all", "-a 全局").option("days", "-d 切换至天数").action(createHandler(async (scope, options) => {
1894
- const { days, hours } = options;
1895
- if (days) {
1896
- const timeRangeInDays = 24;
1897
- const since = new Date(Date.now() - timeRangeInDays * import_koishi3.Time.day);
1898
- const stats = await this.ctx.database.select("analyse_rank").where({ uid: { $in: scope.uids }, timestamp: { $gte: since } }).project(["timestamp", "count"]).execute();
1899
- if (stats.length === 0) return "暂无统计数据";
1900
- const startOfToday = /* @__PURE__ */ new Date();
1901
- startOfToday.setHours(0, 0, 0, 0);
1902
- const dailyCounts = Array(timeRangeInDays).fill(0);
1903
- const dayLabels = Array(timeRangeInDays).fill("");
1904
- for (let i = 0; i < timeRangeInDays; i++) {
1905
- const d = new Date(startOfToday.getTime() - i * import_koishi3.Time.day);
1906
- dayLabels[timeRangeInDays - 1 - i] = String(d.getDate());
1907
- }
1908
- stats.forEach((stat) => {
1909
- const statDayStart = new Date(stat.timestamp);
1910
- statDayStart.setHours(0, 0, 0, 0);
1911
- const daysAgo = Math.round((startOfToday.getTime() - statDayStart.getTime()) / import_koishi3.Time.day);
1912
- if (daysAgo >= 0 && daysAgo < timeRangeInDays) {
1913
- const index = timeRangeInDays - 1 - daysAgo;
1914
- dailyCounts[index] += stat.count;
1915
- }
1916
- });
1917
- const totalMessages = dailyCounts.reduce((a, b) => a + b, 0);
1918
- const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "活跃", timeRange: timeRangeInDays, timeUnit: "天" });
1919
- return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total: totalMessages, data: dailyCounts, labels: dayLabels });
1920
- } else {
1921
- const timeWindowHours = 24;
1922
- const offsetHours = typeof hours === "number" ? hours : 0;
1923
- const now = /* @__PURE__ */ new Date();
1924
- const until = new Date(now.getTime() - offsetHours * import_koishi3.Time.hour);
1925
- const since = new Date(until.getTime() - timeWindowHours * import_koishi3.Time.hour);
1926
- const hourlyStats = await this.ctx.database.select("analyse_rank").where({ uid: { $in: scope.uids }, timestamp: { $gte: since, $lt: until } }).groupBy("timestamp", { count: /* @__PURE__ */ __name((row) => import_koishi3.$.sum(row.count), "count") }).execute();
1927
- if (hourlyStats.length === 0) return "暂无统计数据";
1928
- const processedCounts = Array(timeWindowHours).fill(0);
1929
- const hourLabels = Array(timeWindowHours).fill("");
1930
- for (let i = 0; i < timeWindowHours; i++) {
1931
- const d = new Date(until.getTime() - (i + 1) * import_koishi3.Time.hour);
1932
- hourLabels[timeWindowHours - 1 - i] = String(d.getHours());
1933
- }
1934
- hourlyStats.forEach((stat) => {
1935
- const hoursBeforeUntil = Math.floor((until.getTime() - stat.timestamp.getTime()) / import_koishi3.Time.hour);
1936
- if (hoursBeforeUntil >= 0 && hoursBeforeUntil < timeWindowHours) {
1937
- const index = timeWindowHours - 1 - hoursBeforeUntil;
1938
- processedCounts[index] += stat.count;
1939
- }
1940
- });
1941
- const totalMessages = processedCounts.reduce((a, b) => a + b, 0);
1942
- const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "活跃", timeRange: timeWindowHours, timeUnit: "小时" });
1943
- return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total: totalMessages, data: processedCounts, labels: hourLabels });
1971
+ cmd.subcommand("activity", "活跃统计").usage("查询活跃统计,可指定查询范围,默认当前群组。").option("user", "-u <user:string> 指定用户").option("guild", "-g <guildId:string> 指定群组").option("duration", "-n <units:number> 指定时长", { fallback: 24 }).option("offset", "-o <units:number> 指定偏移").option("days", "-d 以天为粒度").option("all", "-a 全局统计").action(({ session, options }) => handleAction(session, (async () => {
1972
+ const scope = await this.parseScope(session, options);
1973
+ if (scope.error) return scope.error;
1974
+ const timeUnit = options.days ? import_koishi3.Time.day : import_koishi3.Time.hour;
1975
+ const timeUnitName = options.days ? "天" : "小时";
1976
+ const points = options.days ? 24 : 24;
1977
+ const until = new Date(Date.now() - options.offset * timeUnit);
1978
+ const since = new Date(until.getTime() - options.duration * timeUnit);
1979
+ const query = { timestamp: { $gte: since, $lt: until } };
1980
+ if (scope.uids) query.uid = { $in: scope.uids };
1981
+ const stats = await this.ctx.database.select("analyse_rank").where(query).project(["timestamp", "count"]).execute();
1982
+ if (stats.length === 0) return "暂无统计数据";
1983
+ const counts = Array(points).fill(0);
1984
+ const labels = Array(points).fill("");
1985
+ const now = /* @__PURE__ */ new Date();
1986
+ now.setMinutes(0, 0, 0);
1987
+ for (let i = 0; i < points; i++) {
1988
+ const pointTime = new Date(until.getTime() - (i + 1) * timeUnit);
1989
+ labels[points - 1 - i] = options.days ? String(pointTime.getDate()) : String(pointTime.getHours());
1944
1990
  }
1945
- }));
1991
+ stats.forEach((stat) => {
1992
+ const diff = until.getTime() - stat.timestamp.getTime();
1993
+ const index = points - 1 - Math.floor(diff / timeUnit);
1994
+ if (index >= 0 && index < points) {
1995
+ counts[index] += stat.count;
1996
+ }
1997
+ });
1998
+ const total = counts.reduce((a, b) => a + b, 0);
1999
+ const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "活跃", timeRange: options.duration, timeUnit: timeUnitName });
2000
+ return this.renderer.renderCircadianChart({ title, time: /* @__PURE__ */ new Date(), total, data: counts, labels });
2001
+ })()));
1946
2002
  }
1947
2003
  }
1948
2004
  };
@@ -1975,12 +2031,12 @@ var WhoAt = class {
1975
2031
  registerCommand(cmd) {
1976
2032
  cmd.subcommand("whoatme", "谁提及我").usage("查看最近提及我的消息,查看后自动删除。").action(async ({ session }) => {
1977
2033
  if (!session.userId) return "无法获取用户信息";
2034
+ const records = await this.ctx.database.get("analyse_at", { target: session.userId }, {
2035
+ sort: { timestamp: "asc" },
2036
+ limit: 100
2037
+ });
2038
+ if (records.length === 0) return "最近没有人提及您";
1978
2039
  try {
1979
- const records = await this.ctx.database.get("analyse_at", { target: session.userId }, {
1980
- sort: { timestamp: "asc" },
1981
- limit: 100
1982
- });
1983
- if (records.length === 0) return "最近没有人提及您";
1984
2040
  const uids = [...new Set(records.map((r) => r.uid))];
1985
2041
  const users = await this.ctx.database.get("analyse_user", { uid: { $in: uids } }, ["uid", "userName", "userId"]);
1986
2042
  const userInfoMap = new Map(users.map((u) => [u.uid, { name: u.userName, id: u.userId }]));
@@ -1989,9 +2045,9 @@ var WhoAt = class {
1989
2045
  const author = (0, import_koishi4.h)("author", { id: senderInfo.id, name: senderInfo.name });
1990
2046
  return (0, import_koishi4.h)("message", {}, [author, import_koishi4.h.text(record.content)]);
1991
2047
  });
2048
+ await session.send((0, import_koishi4.h)("message", { forward: true }, messageElements));
1992
2049
  const recordIdsToDelete = records.map((r) => r.id);
1993
2050
  await this.ctx.database.remove("analyse_at", { id: { $in: recordIdsToDelete } });
1994
- return (0, import_koishi4.h)("message", { forward: true }, messageElements);
1995
2051
  } catch (error) {
1996
2052
  this.ctx.logger.error("查询提及记录失败:", error);
1997
2053
  return "查询失败,请稍后重试";
@@ -2074,7 +2130,7 @@ var Data = class {
2074
2130
  return "数据恢复失败";
2075
2131
  }
2076
2132
  });
2077
- cmd.subcommand(".clear", "清除数据", { authority: 4 }).usage(`清除指定统计数据,可精确控制清除范围。`).option("table", "-t <table:string> 指定表名").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("days", "-d <days:number> 指定天数").option("command", "-c <command:string> 指定命令").option("all", "-a 清除全部").action(async ({ options }) => {
2133
+ cmd.subcommand(".clear", "清除数据", { authority: 4 }).usage(`清除指定统计数据,可精确控制清除范围。`).option("table", "-t <table:string> 指定表名").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("days", "-d <days:number> 指定天数").option("command", "-c <command:string> 指定命令").option("all", "-a 全部清除").action(async ({ options }) => {
2078
2134
  if (Object.keys(options).length === 0) return "请指定清除条件";
2079
2135
  if (options.table && !ALL_TABLES.includes(options.table)) return `表名 ${options.table} 无效`;
2080
2136
  try {
@@ -2174,7 +2230,7 @@ var Analyse = class {
2174
2230
  */
2175
2231
  registerCommands(cmd) {
2176
2232
  if (this.config.enableWordCloud) {
2177
- cmd.subcommand("wordcloud", "生成词云").usage("基于聊天记录生成词云图,可指定范围,默认当前群组。").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("hours", "-t <hours:number> 指定时长", { fallback: 24 }).option("all", "-a 全局").action(async ({ session, options }) => {
2233
+ cmd.subcommand("wordcloud", "生成词云").usage("基于聊天记录生成词云图,可指定范围,默认当前群组。").option("guild", "-g <guildId:string> 指定群组").option("user", "-u <user:string> 指定用户").option("hours", "-t <hours:number> 指定时长", { fallback: 24 }).action(async ({ session, options }) => {
2178
2234
  try {
2179
2235
  if (!this.jieba) return "Jieba 分词服务未就绪";
2180
2236
  const scope = await parseQueryScope(this.ctx, session, options);
@@ -2196,7 +2252,7 @@ var Analyse = class {
2196
2252
  const wordList = Array.from(wordCounts.entries()).sort((a, b) => b[1] - a[1]);
2197
2253
  const topWordsPreview = wordList.slice(0, 10).map((item) => item[0]).join(", ");
2198
2254
  session.send(`正在生成词云,热门词汇:${topWordsPreview}...`);
2199
- const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "词云" });
2255
+ const title = await generateTitle(this.ctx, scope.scopeDesc, { main: "词云", timeRange: options.hours });
2200
2256
  const imageGenerator = this.renderer.renderWordCloud({ title, time: /* @__PURE__ */ new Date(), words: wordList });
2201
2257
  for await (const buffer of imageGenerator) await session.send(import_koishi6.h.image(buffer, "image/png"));
2202
2258
  } catch (error) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-chat-analyse",
3
3
  "description": "强大而全面的聊天数据分析,支持统计命令,发言,消息类型,活跃度,支持发言排行和生成词云",
4
- "version": "1.3.0",
4
+ "version": "1.3.2",
5
5
  "contributors": [
6
6
  "Yis_Rime <yis_rime@outlook.com>"
7
7
  ],
package/readme.md CHANGED
@@ -8,74 +8,121 @@
8
8
 
9
9
  - **高效数据收集**:采用异步、高并发和缓存机制,在不影响机器人性能的前提下,精确高效地收集聊天数据。
10
10
  - **多维度统计分析**:
11
- - **命令统计** (`cmdstat`):追踪指令使用频率,了解用户最常用的功能。
12
- - **发言统计** (`msgstat`):分析用户发言类型与数量,掌握核心用户群体。
13
- - **发言排行** (`rankstat`):生成指定时间范围内的用户发言排行榜。
14
- - **活跃度分析** (`activity`):以小时或天为单位,生成直观的周期性活跃度图表。
11
+ - **命令统计** (`cmdstat`):追踪指令使用频率,了解用户最常用的功能,支持按次数或时间排序。
12
+ - **发言统计** (`msgstat`):分析用户发言类型与数量,掌握核心用户群体,支持按条数或时间排序。
13
+ - **发言排行** (`rankstat`):生成指定时间范围内的用户发言排行榜,发掘群聊中的“龙王”。
14
+ - **活跃度分析** (`activity`):以小时或天为单位,生成直观的周期性活跃度图表,洞察社群活跃时段。
15
15
  - **高级文本分析**:
16
- - **词云生成** (`wordcloud`):基于聊天记录,利用 Jieba 分词生成热门话题词云图。
17
- - **提及追踪** (`whoatme`):轻松查询谁在什么时候因为什么内容提及了您。
16
+ - **词云生成** (`wordcloud`):基于聊天记录,利用 Jieba 分词生成热门话题词云图,快速了解近期热点。
17
+ - **提及追踪** (`whoatme`):轻松查询谁在什么时候因为什么内容提及了您,不再错过重要信息。
18
18
  - **强大的数据管理**:
19
- - **备份与恢复** (`backup`/`restore`):一键备份所有统计数据至本地,并可随时恢复,保障数据安全。
20
- - **精确清理** (`clear`):提供多维度的筛选条件(如按时间、用户、群组、命令),精确清理不再需要的数据。
19
+ - **备份与恢复** (`.backup`/`.restore`):一键备份所有统计数据至本地,并可随时恢复,保障数据安全。
20
+ - **精确清理** (`.clear`):提供多维度的筛选条件(如按时间、用户、群组、命令),精确清理不再需要的数据。
21
21
  - **精美图表渲染**:借助 Puppeteer 服务,将复杂的统计数据渲染成美观、易读的图片,方便在聊天中分享。
22
22
  - **高度可配置**:所有功能模块均可独立开关,并可自定义数据保留时长等核心参数,以适应不同场景的需求。
23
23
 
24
- ### 前置服务
24
+ ## ⚙️ 前置服务
25
25
 
26
26
  本插件依赖以下 Koishi 服务,请确保您已正确安装并启用了它们:
27
27
 
28
- - `database`:用于存储所有统计分析数据。
29
- - `puppeteer`:用于将统计结果渲染成图片。
30
- - `cron`:用于执行数据定时清理任务。
28
+ - **`database`**:用于存储所有统计分析数据。
29
+ - **`puppeteer`**:用于将统计结果渲染成图片。
30
+ - **`cron`**:用于执行数据定时清理任务。
31
31
 
32
32
  ## 📝 使用说明
33
33
 
34
- 所有功能都集成在主指令 `analyse` 之下。
34
+ 所有功能都集成在主指令 `analyse` 之下。大部分指令不加任何参数时,默认查询**当前群组**的数据。
35
35
 
36
36
  ### 指令列表
37
37
 
38
38
  | 指令 | 别名 | 描述 | 选项 |
39
- | --- | --- | --- | --- |
40
- | `analyse.cmdstat` | 命令统计 | 查询命令使用情况 | `-u <user>` (指定用户), `-g <guild>` (指定群组), `-a` (全局), `-h` (分离子指令) |
41
- | `analyse.msgstat` | 发言统计 | 查询用户发言统计 | `-u <user>`, `-g <guild>`, `-a` (全局), `-t <type>` (指定消息类型) |
42
- | `analyse.rankstat` | 发言排行 | 查询指定时间内的发言排行 | `-g <guild>`, `-a` (全局), `-t <type>`, `-h <hours>` (默认24小时) |
43
- | `analyse.activity` | 活跃统计 | 查询周期性活跃度图表 | `-u <user>`, `-g <guild>`, `-a` (全局), `-d` (按天), `-h <hours>` (小时偏移) |
44
- | `analyse.wordcloud` | 生成词云 | 基于聊天记录生成词云图 | `-u <user>`, `-g <guild>`, `-a` (全局), `-h <hours>` (默认24小时) |
45
- | `analyse.whoatme` | 谁提及我 | 查看最近谁提及了您 | (无) |
46
- | `analyse.list` | 列出数据 | 列出已记录的频道和命令 | (无) |
47
- | `analyse.backup` | 备份数据 | 将所有数据备份为本地 JSON 文件 | (无) |
48
- | `analyse.restore` | 恢复数据 | 从本地 JSON 文件恢复数据 | (无) |
49
- | `analyse.clear` | 清除数据 | 根据条件精确清理数据 | `-t <table>`, `-g <guild>`, `-u <user>`, `-d <days>`, `-c <command>`, `-a` (全部) |
50
-
51
- **通用查询范围说明:**
52
-
53
- - 不带任何范围选项时,默认查询**当前群组**。
54
- - 使用 `-u <user>` 可指定用户 (支持 @某人)。
55
- - 使用 `-g <guild>` 可指定群组 ID。
56
- - 使用 `-a` 将忽略其他范围限制,查询**全局**数据。
57
-
58
- ### 配置项
39
+ | :--- | :--- | :--- | :--- |
40
+ | `cmdstat` | 命令统计 | 查询命令使用情况,展示方式根据选项变化 | `-u`, `-g`, `-a`, `-p`, `-s` |
41
+ | `msgstat` | 发言统计 | 查询用户发言统计,展示方式根据选项变化 | `-u`, `-g`, `-a`, `-t`, `-s` |
42
+ | `rankstat` | 发言排行 | 查询指定时间内的发言排行,展示方式根据选项变化 | `-u`, `-g`, `-a`, `-t`, `-n`, `-o` |
43
+ | `activity` | 活跃统计 | 查询周期性活跃度图表 | `-u`, `-g`, `-a`, `-d`, `-n`, `-o` |
44
+ | `wordcloud` | 生成词云 | 基于聊天记录生成词云图 | `-u`, `-g`, `-t` |
45
+ | `whoatme` | 谁提及我 | 查看最近谁提及了您 | (无) |
46
+ | `analyse.list` | 列出数据 | (管理) 列出已记录的频道和命令 | (无) |
47
+ | `analyse.backup` | 备份数据 | (管理) 将所有数据备份为本地 JSON 文件 | (无) |
48
+ | `analyse.restore` | 恢复数据 | (管理) 从本地 JSON 文件恢复数据 | (无) |
49
+ | `analyse.clear` | 清除数据 | (管理) 根据条件精确清理数据 | `-t`, `-g`, `-u`, `-d`, `-c`, `-a` |
50
+
51
+ **通用选项说明:**
52
+
53
+ - `-u, --user <user>`: 指定用户 (可使用 @ 或 userID)。
54
+ - `-g, --guild <guild>`: 指定群组 (需使用群组ID)。
55
+ - `-a, --all`: 查询全局数据。
56
+
57
+ ### 🔎 指令详解
58
+
59
+ #### `cmdstat` (命令统计)
60
+
61
+ - **`cmdstat`**: 查询**当前群组**所有用户的命令统计。
62
+ - **`cmdstat -u @用户`**: 查询**指定用户**在**所有群组**的命令统计。
63
+ - **`cmdstat -g <群组ID>`**: 查询**指定群组**所有用户的命令统计。
64
+ - **`cmdstat -u @用户 -g <群组ID>`**: 查询**指定用户**在**指定群组**的命令统计。
65
+ - **`cmdstat -a`**: 查询**全局**(所有用户+所有群组)的命令统计。
66
+ - **选项 `-p, --separate`**: 分离展示子命令,不合并到主命令。
67
+ - **选项 `-s, --sortByTime`**: 按最后使用时间降序排序(默认按使用次数)。
68
+
69
+ #### `msgstat` (发言统计)
70
+
71
+ - **`msgstat`**: 查询**当前群组**的发言统计 (按**用户**展示)。
72
+ - **`msgstat -u @用户`**: 查询**指定用户**的发言统计 (按**群组**展示)。
73
+ - **`msgstat -g <群组ID>`**: 查询**指定群组**的发言统计 (按**用户**展示)。
74
+ - **`msgstat -u @用户 -g <群组ID>`**: 查询**指定用户**在**指定群组**的发言统计 (按**消息类型**展示)。
75
+ - **`msgstat -a`**: 查询**全局**发言统计 (按**用户**展示)。
76
+ - **选项 `-t, --type <类型>`**: 筛选指定消息类型 (`text`, `face`, `image` 等),不改变上述展示逻辑。
77
+ - **选项 `-s, --sortByTime`**: 按最后发言时间降序排序(默认按发言条数)。
78
+
79
+ #### `rankstat` (发言排行)
80
+
81
+ - **`rankstat`**: 查询**当前群组**的发言排行 (按**用户**排名)。
82
+ - **`rankstat -u @用户`**: 查询**指定用户**的发言排行 (按**群组**排名)。
83
+ - **`rankstat -u @用户 -g <群组ID>`**: 查询**指定用户**在**指定群组**的发言排行 (按**消息类型**排名)。
84
+ - **`rankstat -a`**: 查询**全局**发言排行 (按**用户**排名)。
85
+ - **选项 `-n, --duration <小时数>`**: 指定查询范围的时长,默认为 `24`。
86
+ - **选项 `-o, --offset <小时数>`"**: 指定查询结束时间的偏移量(从现在往前推的小时数),默认为 `0`。
87
+ - **选项 `-t, --type <类型>`**: 筛选指定消息类型。
88
+
89
+ #### `activity` (活跃统计)
90
+
91
+ - **`activity`**: 查询**当前群组**的活跃度。
92
+ - **`activity -a`**: 查询**全局**活跃度。
93
+ - **`activity -u @用户 -g <群组ID>`**: 查询**指定用户**在**指定群组**的活跃度。
94
+ - **选项 `-n, --duration <数值>`**: 指定查询范围的时长,默认为 `24`。
95
+ - **选项 `-o, --offset <数值>`**: 指定查询结束时间的偏移量,默认为 `0`。
96
+ - **选项 `-d, --days`**: 将 `-n` 和 `-o` 的单位从**小时**切换为**天**。
97
+
98
+ ## 🔧 配置项
59
99
 
60
100
  您可以在插件的配置页面中调整以下选项:
61
101
 
62
- #### 杂项配置
102
+ ### 杂项配置
63
103
 
64
104
  - `enableListener`: **启用消息监听**。总开关,关闭后插件将停止所有数据收集。 (默认: `true`)
65
- - `enableDataIO`: **启用数据管理**。控制 `.backup`, `.restore`, `.clear`, `.list` 等指令的可用性。 (默认: `true`)
105
+ - `enableDataIO`: **启用数据管理**。控制 `.backup`, `.restore`, `.clear`, `.list` 等管理指令的可用性。 (默认: `true`)
66
106
 
67
- #### 基础分析配置
107
+ ### 基础分析配置
68
108
 
69
109
  - `enableCmdStat`: **启用命令统计**。 (默认: `true`)
70
110
  - `enableMsgStat`: **启用消息统计**。 (默认: `true`)
71
111
  - `enableActivity`: **启用活跃统计**。 (默认: `true`)
72
112
  - `enableRankStat`: **启用发言排行**。 (默认: `true`)
73
- - `rankRetentionDays`: **排行保留天数**。发言排行数据的保留时长(天),`0` 为永久保留。 (默认: `31`)
113
+ - `rankRetentionDays`: **排行保留天数**。发言排行数据的保留时长(天),`0` 为永久保留。 (默认: `180`)
74
114
  - `enableWhoAt`: **启用提及记录**。 (默认: `true`)
75
- - `atRetentionDays`: **提及保留天数**。`whoatme` 数据的保留时长(天),`0` 为永久保留。 (默认: `7`)
115
+ - `atRetentionDays`: **提及保留天数**。`whoatme` 数据的保留时长(天),`0` 为永久保留。 (默认: `3`)
76
116
 
77
- #### 高级分析配置
117
+ ### 高级分析配置
78
118
 
79
119
  - `enableOriRecord`: **启用原始记录**。是否记录原始消息内容以供词云等功能使用。 (默认: `true`)
80
120
  - `enableWordCloud`: **启用词云生成**。 (默认: `true`)
81
- - `cacheRetentionDays`: **原始记录保留天数**。原始消息记录的保留时长(天),`0` 为永久保留。 (默认: `180`)
121
+ - `cacheRetentionDays`: **原始记录保留天数**。原始消息记录的保留时长(天),`0` 为永久保留。 (默认: `30`)
122
+
123
+ ## 📌 注意事项
124
+
125
+ 1. **Puppeteer 配置**:本插件的图片渲染强依赖 `puppeteer` 服务。请确保您已正确安装并配置了该服务,包括正确设置了可执行文件路径(如有需要)。渲染失败通常与此有关。
126
+ 2. **初始数据积累**:插件启用后,需要一段时间来收集数据。因此,在刚安装插件后立即查询可能不会返回任何结果。
127
+ 3. **数据清理**:插件会根据您设置的保留天数自动清理过期数据。对于手动清理 (`.clear`),请谨慎操作,特别是 `-a` (清除全部) 选项,该操作不可逆。
128
+ 4. **性能考虑**:尽管插件经过优化,但在极大规模的机器人(数千群聊)上,长时间积累的大量数据仍可能对数据库造成压力。建议定期使用 `.clear -d <天数>` 清理过旧的数据。