@zhin.js/plugin-ledger 0.0.0-stage → 1.0.1

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 (72) hide show
  1. package/README.md +83 -2
  2. package/commands/ledger-add/index.js +17 -0
  3. package/commands/ledger-add/index.ts +17 -0
  4. package/commands/ledger-delete/index.js +17 -0
  5. package/commands/ledger-delete/index.ts +17 -0
  6. package/commands/ledger-list/index.js +21 -0
  7. package/commands/ledger-list/index.ts +20 -0
  8. package/commands/ledger-month/index.js +14 -0
  9. package/commands/ledger-month/index.ts +14 -0
  10. package/commands/ledger-report/index.js +23 -0
  11. package/commands/ledger-report/index.ts +25 -0
  12. package/commands/ledger-stats/index.js +21 -0
  13. package/commands/ledger-stats/index.ts +20 -0
  14. package/commands/ledger-today/index.js +14 -0
  15. package/commands/ledger-today/index.ts +14 -0
  16. package/commands/ledger-week/index.js +14 -0
  17. package/commands/ledger-week/index.ts +14 -0
  18. package/lib/config.d.ts +21 -0
  19. package/lib/config.d.ts.map +1 -0
  20. package/lib/config.js +30 -0
  21. package/lib/config.js.map +1 -0
  22. package/lib/entry-input.d.ts +18 -0
  23. package/lib/entry-input.d.ts.map +1 -0
  24. package/lib/entry-input.js +31 -0
  25. package/lib/entry-input.js.map +1 -0
  26. package/lib/identity.d.ts +22 -0
  27. package/lib/identity.d.ts.map +1 -0
  28. package/lib/identity.js +37 -0
  29. package/lib/identity.js.map +1 -0
  30. package/lib/index.d.ts +10 -0
  31. package/lib/index.d.ts.map +1 -0
  32. package/lib/index.js +10 -0
  33. package/lib/index.js.map +1 -0
  34. package/lib/money.d.ts +20 -0
  35. package/lib/money.d.ts.map +1 -0
  36. package/lib/money.js +48 -0
  37. package/lib/money.js.map +1 -0
  38. package/lib/period.d.ts +21 -0
  39. package/lib/period.d.ts.map +1 -0
  40. package/lib/period.js +98 -0
  41. package/lib/period.js.map +1 -0
  42. package/lib/report-format.d.ts +30 -0
  43. package/lib/report-format.d.ts.map +1 -0
  44. package/lib/report-format.js +98 -0
  45. package/lib/report-format.js.map +1 -0
  46. package/lib/runtime.d.ts +3 -0
  47. package/lib/runtime.d.ts.map +1 -0
  48. package/lib/runtime.js +3 -0
  49. package/lib/runtime.js.map +1 -0
  50. package/lib/service.d.ts +33 -0
  51. package/lib/service.d.ts.map +1 -0
  52. package/lib/service.js +206 -0
  53. package/lib/service.js.map +1 -0
  54. package/lib/store.d.ts +48 -0
  55. package/lib/store.d.ts.map +1 -0
  56. package/lib/store.js +162 -0
  57. package/lib/store.js.map +1 -0
  58. package/package.json +90 -3
  59. package/plugin.js +19 -0
  60. package/schedules/daily-report/index.js +10 -0
  61. package/schedules/daily-report/index.ts +10 -0
  62. package/schema.json +31 -0
  63. package/src/config.ts +39 -0
  64. package/src/entry-input.ts +39 -0
  65. package/src/identity.ts +50 -0
  66. package/src/index.ts +29 -0
  67. package/src/money.ts +53 -0
  68. package/src/period.ts +101 -0
  69. package/src/report-format.ts +125 -0
  70. package/src/runtime.ts +4 -0
  71. package/src/service.ts +217 -0
  72. package/src/store.ts +195 -0
package/src/period.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Calendar periods evaluated in the configured IANA timezone. Entries are compared by their
3
+ * local calendar date (YYYY-MM-DD), so daylight-saving shifts never move an entry into another day.
4
+ */
5
+
6
+ export type LedgerPeriod = 'today' | 'week' | 'month' | 'all';
7
+
8
+ const DAY_MS = 86_400_000;
9
+
10
+ const PERIOD_LABELS: Readonly<Record<LedgerPeriod, string>> = {
11
+ today: '今日',
12
+ week: '本周',
13
+ month: '本月',
14
+ all: '全部',
15
+ };
16
+
17
+ export function periodLabel(period: LedgerPeriod): string {
18
+ return PERIOD_LABELS[period];
19
+ }
20
+
21
+ /** Maps a user-typed period word to a period; undefined when the word is not recognised. */
22
+ export function parsePeriodWord(word: string | undefined, fallback: LedgerPeriod): LedgerPeriod | undefined {
23
+ if (word === undefined || word.trim() === '') return fallback;
24
+ const normalized = word.trim();
25
+ if (normalized === '今日' || normalized === '今天') return 'today';
26
+ if (normalized === '本周' || normalized === '这周') return 'week';
27
+ if (normalized === '本月' || normalized === '这个月') return 'month';
28
+ if (normalized === '全部' || normalized === '所有') return 'all';
29
+ return undefined;
30
+ }
31
+
32
+ function partsIn(instantMs: number, timeZone: string): Record<string, string> {
33
+ const formatter = new Intl.DateTimeFormat('en-US', {
34
+ timeZone,
35
+ year: 'numeric',
36
+ month: '2-digit',
37
+ day: '2-digit',
38
+ hour: '2-digit',
39
+ minute: '2-digit',
40
+ hourCycle: 'h23',
41
+ });
42
+ const parts: Record<string, string> = {};
43
+ for (const part of formatter.formatToParts(instantMs)) parts[part.type] = part.value;
44
+ return parts;
45
+ }
46
+
47
+ /** Local calendar date `YYYY-MM-DD` of an instant in a timezone. */
48
+ export function localDateKey(instantMs: number, timeZone: string): string {
49
+ const parts = partsIn(instantMs, timeZone);
50
+ return `${parts.year}-${parts.month}-${parts.day}`;
51
+ }
52
+
53
+ /** Local wall-clock time `HH:MM` of an instant in a timezone. */
54
+ export function localTimeKey(instantMs: number, timeZone: string): string {
55
+ const parts = partsIn(instantMs, timeZone);
56
+ return `${parts.hour}:${parts.minute}`;
57
+ }
58
+
59
+ function dateKeyToUtcMs(dateKey: string): number {
60
+ const [year, month, day] = dateKey.split('-').map(Number);
61
+ return Date.UTC(year!, month! - 1, day!);
62
+ }
63
+
64
+ function utcMsToDateKey(utcMs: number): string {
65
+ return new Date(utcMs).toISOString().slice(0, 10);
66
+ }
67
+
68
+ /** Monday of the ISO week containing a local date. */
69
+ export function weekStartKey(dateKey: string): string {
70
+ const utcMs = dateKeyToUtcMs(dateKey);
71
+ const dayOfWeek = new Date(utcMs).getUTCDay();
72
+ const daysSinceMonday = (dayOfWeek + 6) % 7;
73
+ return utcMsToDateKey(utcMs - daysSinceMonday * DAY_MS);
74
+ }
75
+
76
+ /** Sunday that ends the ISO week starting on `weekStart`. */
77
+ export function weekEndKey(weekStart: string): string {
78
+ return utcMsToDateKey(dateKeyToUtcMs(weekStart) + 6 * DAY_MS);
79
+ }
80
+
81
+ /** Whether an entry made at `entryMs` falls in `period` as seen at `nowMs`. */
82
+ export function isInPeriod(entryMs: number, nowMs: number, period: LedgerPeriod, timeZone: string): boolean {
83
+ if (period === 'all') return true;
84
+ const entryDate = localDateKey(entryMs, timeZone);
85
+ const today = localDateKey(nowMs, timeZone);
86
+ if (period === 'today') return entryDate === today;
87
+ if (period === 'month') return entryDate.slice(0, 7) === today.slice(0, 7);
88
+ return weekStartKey(entryDate) === weekStartKey(today);
89
+ }
90
+
91
+ /** Human-readable range for a period header, e.g. `2026-10-19 ~ 2026-10-25`. */
92
+ export function describePeriodRange(nowMs: number, period: LedgerPeriod, timeZone: string): string {
93
+ const today = localDateKey(nowMs, timeZone);
94
+ if (period === 'today') return today;
95
+ if (period === 'month') return today.slice(0, 7);
96
+ if (period === 'week') {
97
+ const start = weekStartKey(today);
98
+ return `${start} ~ ${weekEndKey(start)}`;
99
+ }
100
+ return '全部记录';
101
+ }
@@ -0,0 +1,125 @@
1
+ import { formatCents, MASKED_AMOUNT } from './money.js';
2
+ import { describePeriodRange, localDateKey, periodLabel, type LedgerPeriod } from './period.js';
3
+ import type { LedgerEntry } from './store.js';
4
+
5
+ /** Total, entry count and share for one category. */
6
+ export interface CategoryTotal {
7
+ readonly category: string;
8
+ readonly totalCents: number;
9
+ readonly count: number;
10
+ readonly largestCents: number;
11
+ }
12
+
13
+ export interface PeriodSummary {
14
+ readonly period: LedgerPeriod;
15
+ readonly range: string;
16
+ readonly totalCents: number;
17
+ readonly count: number;
18
+ readonly categories: readonly CategoryTotal[];
19
+ }
20
+
21
+ /** A reply in two versions: the full one for the owner, the masked one for public conversations. */
22
+ export interface LedgerReply {
23
+ readonly full: string;
24
+ readonly masked: string;
25
+ }
26
+
27
+ /** Groups entries by category, largest total first (ties by name for a stable order). */
28
+ export function totalsByCategory(entries: readonly LedgerEntry[]): CategoryTotal[] {
29
+ const byCategory = new Map<string, { totalCents: number; count: number; largestCents: number }>();
30
+ for (const entry of entries) {
31
+ const current = byCategory.get(entry.category) ?? { totalCents: 0, count: 0, largestCents: 0 };
32
+ current.totalCents += entry.amountCents;
33
+ current.count += 1;
34
+ current.largestCents = Math.max(current.largestCents, entry.amountCents);
35
+ byCategory.set(entry.category, current);
36
+ }
37
+ const totals = [...byCategory.entries()].map(([category, value]) => ({ category, ...value }));
38
+ totals.sort((left, right) => right.totalCents - left.totalCents || left.category.localeCompare(right.category));
39
+ return totals;
40
+ }
41
+
42
+ export function summarize(entries: readonly LedgerEntry[], period: LedgerPeriod, nowMs: number, timeZone: string): PeriodSummary {
43
+ let totalCents = 0;
44
+ for (const entry of entries) totalCents += entry.amountCents;
45
+ return {
46
+ period,
47
+ range: describePeriodRange(nowMs, period, timeZone),
48
+ totalCents,
49
+ count: entries.length,
50
+ categories: totalsByCategory(entries),
51
+ };
52
+ }
53
+
54
+ /** Share of the total with one decimal, computed from integers. */
55
+ function sharePercent(partCents: number, totalCents: number): string {
56
+ if (totalCents === 0) return '0';
57
+ const tenths = Math.round((partCents * 1000) / totalCents);
58
+ return `${Math.floor(tenths / 10)}.${tenths % 10}`;
59
+ }
60
+
61
+ export function formatPeriodSummary(summary: PeriodSummary): LedgerReply {
62
+ const label = periodLabel(summary.period);
63
+ if (summary.count === 0) {
64
+ const empty = `📒 ${label}还没有记账(${summary.range})`;
65
+ return { full: empty, masked: empty };
66
+ }
67
+ const header = `📒 ${label}消费(${summary.range})`;
68
+ const fullLines = [header, `合计 ${formatCents(summary.totalCents)} · ${summary.count} 笔`];
69
+ const maskedLines = [header, `合计 ${MASKED_AMOUNT} · ${summary.count} 笔`];
70
+ for (const category of summary.categories) {
71
+ const share = sharePercent(category.totalCents, summary.totalCents);
72
+ fullLines.push(`· ${category.category} ${formatCents(category.totalCents)}(${category.count} 笔,${share}%)`);
73
+ maskedLines.push(`· ${category.category} ${MASKED_AMOUNT}(${category.count} 笔)`);
74
+ }
75
+ return { full: fullLines.join('\n'), masked: maskedLines.join('\n') };
76
+ }
77
+
78
+ export function formatCategoryStatistics(summary: PeriodSummary): LedgerReply {
79
+ const label = periodLabel(summary.period);
80
+ if (summary.count === 0) {
81
+ const empty = `📊 ${label}没有可统计的记账(${summary.range})`;
82
+ return { full: empty, masked: empty };
83
+ }
84
+ const header = `📊 分类统计 · ${label}(${summary.range})`;
85
+ const fullLines = [header];
86
+ const maskedLines = [header];
87
+ for (const category of summary.categories) {
88
+ const averageCents = Math.round(category.totalCents / category.count);
89
+ const share = sharePercent(category.totalCents, summary.totalCents);
90
+ fullLines.push(`· ${category.category}:合计 ${formatCents(category.totalCents)},${category.count} 笔,平均 ${formatCents(averageCents)},最大 ${formatCents(category.largestCents)},占比 ${share}%`);
91
+ maskedLines.push(`· ${category.category}:${category.count} 笔,金额已隐藏`);
92
+ }
93
+ fullLines.push(`共 ${summary.categories.length} 个类别 · 合计 ${formatCents(summary.totalCents)}`);
94
+ maskedLines.push(`共 ${summary.categories.length} 个类别`);
95
+ return { full: fullLines.join('\n'), masked: maskedLines.join('\n') };
96
+ }
97
+
98
+ export const MAX_LISTED_ENTRIES = 20;
99
+
100
+ /** Newest first, with `#id` so users can find what to delete. */
101
+ export function formatEntryList(entries: readonly LedgerEntry[], period: LedgerPeriod, nowMs: number, timeZone: string): LedgerReply {
102
+ const label = periodLabel(period);
103
+ const range = describePeriodRange(nowMs, period, timeZone);
104
+ if (entries.length === 0) {
105
+ const empty = `🧾 ${label}没有账目(${range})`;
106
+ return { full: empty, masked: empty };
107
+ }
108
+ const newestFirst = [...entries].sort((left, right) => right.createdAt - left.createdAt || right.seq - left.seq);
109
+ const shown = newestFirst.slice(0, MAX_LISTED_ENTRIES);
110
+ const header = `🧾 ${label}账单(${range})`;
111
+ const fullLines = [header];
112
+ const maskedLines = [header];
113
+ for (const entry of shown) {
114
+ const date = localDateKey(entry.createdAt, timeZone).slice(5);
115
+ const note = entry.note ? ` ${entry.note}` : '';
116
+ fullLines.push(`#${entry.seq} ${date} ${entry.category} ${formatCents(entry.amountCents)}${note}`);
117
+ maskedLines.push(`#${entry.seq} ${date} ${entry.category} ${MASKED_AMOUNT}`);
118
+ }
119
+ if (newestFirst.length > shown.length) {
120
+ const more = `……另有 ${newestFirst.length - shown.length} 笔未显示`;
121
+ fullLines.push(more);
122
+ maskedLines.push(more);
123
+ }
124
+ return { full: fullLines.join('\n'), masked: maskedLines.join('\n') };
125
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { createToken } from 'zhin.js';
2
+ import type { LedgerService } from './service.js';
3
+
4
+ export const ledgerServiceToken = createToken<LedgerService>('zhin.ledger.service', 'Personal expense ledger');
package/src/service.ts ADDED
@@ -0,0 +1,217 @@
1
+ import { getLogger, type DeliveryTarget, type OutboundHost, type PluginDatabaseHost } from 'zhin.js';
2
+ import type { ResolvedLedgerConfig } from './config.js';
3
+ import { parseEntryInput } from './entry-input.js';
4
+ import type { LedgerCaller } from './identity.js';
5
+ import { formatCents, MASKED_AMOUNT } from './money.js';
6
+ import { isInPeriod, localDateKey, localTimeKey, periodLabel, type LedgerPeriod } from './period.js';
7
+ import {
8
+ formatCategoryStatistics,
9
+ formatEntryList,
10
+ formatPeriodSummary,
11
+ summarize,
12
+ type LedgerReply,
13
+ } from './report-format.js';
14
+ import { LedgerStore, type LedgerEntry } from './store.js';
15
+
16
+ const logger = getLogger('ledger');
17
+
18
+ export type DailyReportAction = 'on' | 'off' | 'status';
19
+
20
+ export interface LedgerServiceOptions {
21
+ /** Wall clock; tests pass a fake. */
22
+ readonly now?: () => number;
23
+ }
24
+
25
+ /**
26
+ * Personal expense ledger. Every operation is scoped to the caller's own `ownerKey`.
27
+ *
28
+ * Group privacy: replies that contain amounts go to the caller's private chat when the platform
29
+ * can deliver there (`OutboundHost.sendTarget` exists and the send succeeds). Otherwise the
30
+ * group gets the masked version. This is a capability check, not a per-platform list.
31
+ */
32
+ export class LedgerService {
33
+ readonly #store: LedgerStore;
34
+ readonly #outbound: OutboundHost | undefined;
35
+ readonly #config: ResolvedLedgerConfig;
36
+ readonly #now: () => number;
37
+ /** Serialises writes per user so two quick `记` commands never share an `#id`. */
38
+ readonly #userQueues = new Map<string, Promise<unknown>>();
39
+ /** Private targets already saved this process, to skip redundant writes. */
40
+ readonly #knownPrivate = new Map<string, string>();
41
+
42
+ constructor(database: PluginDatabaseHost, outbound: OutboundHost | undefined, config: ResolvedLedgerConfig, options: LedgerServiceOptions = {}) {
43
+ this.#store = new LedgerStore(database);
44
+ this.#outbound = outbound;
45
+ this.#config = config;
46
+ this.#now = options.now ?? (() => Date.now());
47
+ }
48
+
49
+ #withUserLock<T>(ownerKey: string, operation: () => Promise<T>): Promise<T> {
50
+ const previous = this.#userQueues.get(ownerKey) ?? Promise.resolve();
51
+ const next = previous.then(operation, operation);
52
+ const settled = next.catch(() => undefined);
53
+ this.#userQueues.set(ownerKey, settled);
54
+ void settled.then(() => {
55
+ if (this.#userQueues.get(ownerKey) === settled) this.#userQueues.delete(ownerKey);
56
+ });
57
+ return next;
58
+ }
59
+
60
+ /**
61
+ * Some platforms (Discord, Lark, Slack, ...) address a private chat by a conversation id that is
62
+ * not the user id. When the user talks to the bot in private we save that real conversation and
63
+ * use it for later private deliveries; until then the user id is the best available guess.
64
+ */
65
+ async #rememberPrivate(caller: LedgerCaller): Promise<void> {
66
+ if (!caller.isPrivate) return;
67
+ const raw = JSON.stringify(caller.conversation);
68
+ if (this.#knownPrivate.get(caller.ownerKey) === raw) return;
69
+ try {
70
+ await this.#store.setPrivateTarget(caller.ownerKey, caller.conversation);
71
+ this.#knownPrivate.set(caller.ownerKey, raw);
72
+ } catch (error) {
73
+ logger.warn(`saving private target failed: ${error instanceof Error ? error.message : String(error)}`);
74
+ }
75
+ }
76
+
77
+ async #privateTargetOf(caller: LedgerCaller): Promise<DeliveryTarget> {
78
+ if (caller.isPrivate) return caller.conversation;
79
+ return (await this.#store.getPrivateTarget(caller.ownerKey)) ?? caller.privateTarget;
80
+ }
81
+
82
+ async addEntry(caller: LedgerCaller, text: string): Promise<string> {
83
+ await this.#rememberPrivate(caller);
84
+ const parsed = parseEntryInput(text);
85
+ if (!parsed.ok) return parsed.reason;
86
+ const { category, amountCents, note } = parsed.entry;
87
+ const seq = await this.#withUserLock(caller.ownerKey, async () => {
88
+ const reserved = await this.#store.reserveSequence(caller.ownerKey);
89
+ await this.#store.insertEntry({
90
+ ownerKey: caller.ownerKey,
91
+ seq: reserved,
92
+ category,
93
+ amountCents,
94
+ note,
95
+ createdAt: this.#now(),
96
+ });
97
+ return reserved;
98
+ });
99
+ const amount = caller.isPrivate ? formatCents(amountCents) : MASKED_AMOUNT;
100
+ const noteText = note ? ` · ${note}` : '';
101
+ return `✅ 已记账 #${seq} · ${category} ${amount}${noteText}`;
102
+ }
103
+
104
+ async deleteEntry(caller: LedgerCaller, seq: number): Promise<string> {
105
+ await this.#rememberPrivate(caller);
106
+ return this.#withUserLock(caller.ownerKey, async () => {
107
+ const entry = await this.#store.findEntry(caller.ownerKey, seq);
108
+ if (!entry) return `没有找到你的账目 #${seq}(只能删除自己的记账,可用「账单」查看编号)`;
109
+ await this.#store.deleteEntry(caller.ownerKey, seq);
110
+ const amount = caller.isPrivate ? formatCents(entry.amountCents) : MASKED_AMOUNT;
111
+ return `🗑️ 已删除 #${seq} · ${entry.category} ${amount}`;
112
+ });
113
+ }
114
+
115
+ async #entriesIn(ownerKey: string, period: LedgerPeriod): Promise<LedgerEntry[]> {
116
+ const nowMs = this.#now();
117
+ const entries = await this.#store.listEntries(ownerKey);
118
+ return entries.filter((entry) => isInPeriod(entry.createdAt, nowMs, period, this.#config.timezone));
119
+ }
120
+
121
+ async periodSummary(caller: LedgerCaller, period: LedgerPeriod): Promise<string> {
122
+ const entries = await this.#entriesIn(caller.ownerKey, period);
123
+ const reply = formatPeriodSummary(summarize(entries, period, this.#now(), this.#config.timezone));
124
+ return this.#deliver(caller, reply, `${periodLabel(period)}消费`);
125
+ }
126
+
127
+ async categoryStatistics(caller: LedgerCaller, period: LedgerPeriod): Promise<string> {
128
+ const entries = await this.#entriesIn(caller.ownerKey, period);
129
+ const reply = formatCategoryStatistics(summarize(entries, period, this.#now(), this.#config.timezone));
130
+ return this.#deliver(caller, reply, '分类统计');
131
+ }
132
+
133
+ async listEntries(caller: LedgerCaller, period: LedgerPeriod): Promise<string> {
134
+ const entries = await this.#entriesIn(caller.ownerKey, period);
135
+ const reply = formatEntryList(entries, period, this.#now(), this.#config.timezone);
136
+ return this.#deliver(caller, reply, `${periodLabel(period)}账单`);
137
+ }
138
+
139
+ /** Private chat: full reply. Group/channel: try a private message, else the masked reply. */
140
+ async #deliver(caller: LedgerCaller, reply: LedgerReply, title: string): Promise<string> {
141
+ if (caller.isPrivate) {
142
+ await this.#rememberPrivate(caller);
143
+ return reply.full;
144
+ }
145
+ if (reply.full === reply.masked) return reply.full;
146
+ const delivered = await this.#sendPrivately(caller, reply.full);
147
+ if (delivered) return `🔒 ${title}已私信发送给你`;
148
+ return `${reply.masked}\n(群聊中金额已隐藏,私聊我可查看完整金额)`;
149
+ }
150
+
151
+ async #sendPrivately(caller: LedgerCaller, content: string): Promise<boolean> {
152
+ const sendTarget = this.#outbound?.sendTarget;
153
+ if (!sendTarget) return false;
154
+ try {
155
+ await sendTarget.call(this.#outbound, await this.#privateTargetOf(caller), content);
156
+ return true;
157
+ } catch (error) {
158
+ logger.debug(`private delivery unavailable: ${error instanceof Error ? error.message : String(error)}`);
159
+ return false;
160
+ }
161
+ }
162
+
163
+ async dailyReport(caller: LedgerCaller, action: DailyReportAction): Promise<string> {
164
+ await this.#rememberPrivate(caller);
165
+ const reportTime = this.#config.reportTime;
166
+ const user = await this.#store.getUser(caller.ownerKey);
167
+ if (action === 'status') {
168
+ if (user?.reportEnabled) return `记账日报已开启:每天 ${reportTime}(${this.#config.timezone})私信发送当天消费`;
169
+ return '记账日报未开启。发送「记账日报 开启」订阅';
170
+ }
171
+ if (action === 'off') {
172
+ await this.#store.setReport(caller.ownerKey, false, undefined, user?.lastReportDate ?? '');
173
+ return '已关闭记账日报';
174
+ }
175
+ if (!this.#outbound?.sendTarget) return '当前环境不支持主动私信,无法开启记账日报';
176
+ const confirmation = `已开启记账日报:每天 ${reportTime}(${this.#config.timezone})私信发送当天消费。发送「记账日报 关闭」取消`;
177
+ if (!caller.isPrivate) {
178
+ const reachable = await this.#sendPrivately(caller, confirmation);
179
+ if (!reachable) return '无法私信你,请先私聊我后再发送「记账日报 开启」';
180
+ }
181
+ const nowMs = this.#now();
182
+ const today = localDateKey(nowMs, this.#config.timezone);
183
+ const alreadyPastReportTime = localTimeKey(nowMs, this.#config.timezone) >= reportTime;
184
+ const lastReportDate = alreadyPastReportTime ? today : '';
185
+ await this.#store.setReport(caller.ownerKey, true, await this.#privateTargetOf(caller), lastReportDate);
186
+ return caller.isPrivate ? confirmation : '🔒 已开启记账日报,确认消息已私信发送给你';
187
+ }
188
+
189
+ /**
190
+ * Called every minute by the schedule. Once the local time reaches `reportTime`, each subscriber
191
+ * who has not had today's report gets it in their private chat. A failed delivery is logged and
192
+ * not retried that day, so an unreachable user is not messaged every minute.
193
+ */
194
+ async runDailyReports(): Promise<number> {
195
+ const nowMs = this.#now();
196
+ const timezone = this.#config.timezone;
197
+ if (localTimeKey(nowMs, timezone) < this.#config.reportTime) return 0;
198
+ const today = localDateKey(nowMs, timezone);
199
+ const subscribers = await this.#store.listReportSubscribers();
200
+ let sent = 0;
201
+ for (const subscriber of subscribers) {
202
+ if (subscriber.lastReportDate === today) continue;
203
+ await this.#store.markReported(subscriber.ownerKey, today);
204
+ if (!subscriber.reportTarget || !this.#outbound?.sendTarget) continue;
205
+ const entries = await this.#entriesIn(subscriber.ownerKey, 'today');
206
+ if (entries.length === 0 && !this.#config.reportWhenEmpty) continue;
207
+ const summary = formatPeriodSummary(summarize(entries, 'today', nowMs, timezone));
208
+ try {
209
+ await this.#outbound.sendTarget(subscriber.reportTarget, `🌙 记账日报\n${summary.full}`);
210
+ sent += 1;
211
+ } catch (error) {
212
+ logger.warn(`daily report delivery failed: ${error instanceof Error ? error.message : String(error)}`);
213
+ }
214
+ }
215
+ return sent;
216
+ }
217
+ }
package/src/store.ts ADDED
@@ -0,0 +1,195 @@
1
+ import type { DatabaseHostModel, DeliveryTarget, PluginDatabaseHost } from 'zhin.js';
2
+
3
+ export const ENTRY_TABLE = 'ledger_entries';
4
+ export const USER_TABLE = 'ledger_users';
5
+ /** Separate table: existing databases only run CREATE TABLE IF NOT EXISTS, so new columns on `ledger_users` would never appear. */
6
+ export const PRIVATE_TARGET_TABLE = 'ledger_private_targets';
7
+
8
+ /** One expense. `seq` is the per-user number shown as `#id`; amounts are integer cents. */
9
+ export interface LedgerEntry {
10
+ readonly ownerKey: string;
11
+ readonly seq: number;
12
+ readonly category: string;
13
+ readonly amountCents: number;
14
+ readonly note: string;
15
+ readonly createdAt: number;
16
+ }
17
+
18
+ /** Per-user settings: the next entry number and the opt-in daily report. */
19
+ export interface LedgerUserRecord {
20
+ readonly ownerKey: string;
21
+ readonly nextSeq: number;
22
+ readonly reportEnabled: boolean;
23
+ readonly reportTarget: DeliveryTarget | undefined;
24
+ readonly lastReportDate: string;
25
+ }
26
+
27
+ /** Registers both tables; called from `setup()` before the database starts. */
28
+ export function defineLedgerTables(database: Pick<PluginDatabaseHost, 'define'>): void {
29
+ database.define(ENTRY_TABLE, {
30
+ owner_key: { type: 'text', nullable: false },
31
+ seq: { type: 'integer', nullable: false },
32
+ category: { type: 'text', nullable: false },
33
+ amount_cents: { type: 'integer', nullable: false },
34
+ note: { type: 'text', default: '' },
35
+ created_at: { type: 'text', nullable: false },
36
+ });
37
+ database.define(USER_TABLE, {
38
+ owner_key: { type: 'text', nullable: false },
39
+ next_seq: { type: 'integer', default: 1 },
40
+ report_enabled: { type: 'integer', default: 0 },
41
+ report_target: { type: 'text', default: '' },
42
+ last_report_date: { type: 'text', default: '' },
43
+ });
44
+ database.define(PRIVATE_TARGET_TABLE, {
45
+ owner_key: { type: 'text', nullable: false },
46
+ target: { type: 'text', nullable: false },
47
+ });
48
+ }
49
+
50
+ function toEntry(row: Record<string, unknown>): LedgerEntry {
51
+ return {
52
+ ownerKey: String(row.owner_key),
53
+ seq: Number(row.seq),
54
+ category: String(row.category),
55
+ amountCents: Number(row.amount_cents),
56
+ note: String(row.note ?? ''),
57
+ createdAt: Date.parse(String(row.created_at)),
58
+ };
59
+ }
60
+
61
+ function parseTarget(raw: unknown): DeliveryTarget | undefined {
62
+ if (typeof raw !== 'string' || raw === '') return undefined;
63
+ try {
64
+ return JSON.parse(raw) as DeliveryTarget;
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ function toUser(row: Record<string, unknown>): LedgerUserRecord {
71
+ return {
72
+ ownerKey: String(row.owner_key),
73
+ nextSeq: Number(row.next_seq ?? 1),
74
+ reportEnabled: Number(row.report_enabled ?? 0) === 1,
75
+ reportTarget: parseTarget(row.report_target),
76
+ lastReportDate: String(row.last_report_date ?? ''),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Every query filters on `owner_key`, the caller's own identity, so one user can never read,
82
+ * number or delete another user's entries. Models are looked up per call because the
83
+ * database only starts after `setup()`.
84
+ */
85
+ export class LedgerStore {
86
+ readonly #database: PluginDatabaseHost;
87
+
88
+ constructor(database: PluginDatabaseHost) {
89
+ this.#database = database;
90
+ }
91
+
92
+ #model(table: string): DatabaseHostModel {
93
+ const model = this.#database.models.get(table);
94
+ if (!model) throw new Error(`记账数据表 ${table} 不可用`);
95
+ return model;
96
+ }
97
+
98
+ async getUser(ownerKey: string): Promise<LedgerUserRecord | undefined> {
99
+ const rows = await this.#model(USER_TABLE)
100
+ .select('owner_key', 'next_seq', 'report_enabled', 'report_target', 'last_report_date')
101
+ .where({ owner_key: ownerKey });
102
+ const first = rows[0];
103
+ return first ? toUser(first) : undefined;
104
+ }
105
+
106
+ async #ensureUser(ownerKey: string): Promise<LedgerUserRecord> {
107
+ const existing = await this.getUser(ownerKey);
108
+ if (existing) return existing;
109
+ await this.#model(USER_TABLE).insert({
110
+ owner_key: ownerKey,
111
+ next_seq: 1,
112
+ report_enabled: 0,
113
+ report_target: '',
114
+ last_report_date: '',
115
+ });
116
+ return { ownerKey, nextSeq: 1, reportEnabled: false, reportTarget: undefined, lastReportDate: '' };
117
+ }
118
+
119
+ /** Reserves the next per-user entry number. Callers serialise per user (see LedgerService). */
120
+ async reserveSequence(ownerKey: string): Promise<number> {
121
+ const user = await this.#ensureUser(ownerKey);
122
+ const reserved = user.nextSeq;
123
+ await this.#model(USER_TABLE).update({ next_seq: reserved + 1 }).where({ owner_key: ownerKey });
124
+ return reserved;
125
+ }
126
+
127
+ async insertEntry(entry: LedgerEntry): Promise<void> {
128
+ await this.#model(ENTRY_TABLE).insert({
129
+ owner_key: entry.ownerKey,
130
+ seq: entry.seq,
131
+ category: entry.category,
132
+ amount_cents: entry.amountCents,
133
+ note: entry.note,
134
+ created_at: new Date(entry.createdAt).toISOString(),
135
+ });
136
+ }
137
+
138
+ async listEntries(ownerKey: string): Promise<LedgerEntry[]> {
139
+ const rows = await this.#model(ENTRY_TABLE)
140
+ .select('owner_key', 'seq', 'category', 'amount_cents', 'note', 'created_at')
141
+ .where({ owner_key: ownerKey });
142
+ return rows.map(toEntry);
143
+ }
144
+
145
+ async findEntry(ownerKey: string, seq: number): Promise<LedgerEntry | undefined> {
146
+ const rows = await this.#model(ENTRY_TABLE)
147
+ .select('owner_key', 'seq', 'category', 'amount_cents', 'note', 'created_at')
148
+ .where({ owner_key: ownerKey, seq });
149
+ const first = rows[0];
150
+ return first ? toEntry(first) : undefined;
151
+ }
152
+
153
+ async deleteEntry(ownerKey: string, seq: number): Promise<void> {
154
+ await this.#model(ENTRY_TABLE).delete().where({ owner_key: ownerKey, seq });
155
+ }
156
+
157
+ async setReport(ownerKey: string, enabled: boolean, target: DeliveryTarget | undefined, lastReportDate: string): Promise<void> {
158
+ await this.#ensureUser(ownerKey);
159
+ await this.#model(USER_TABLE).update({
160
+ report_enabled: enabled ? 1 : 0,
161
+ report_target: target ? JSON.stringify(target) : '',
162
+ last_report_date: lastReportDate,
163
+ }).where({ owner_key: ownerKey });
164
+ }
165
+
166
+ /** The real private conversation saved when the user talked to the bot in private. */
167
+ async getPrivateTarget(ownerKey: string): Promise<DeliveryTarget | undefined> {
168
+ const rows = await this.#model(PRIVATE_TARGET_TABLE).select('owner_key', 'target').where({ owner_key: ownerKey });
169
+ return parseTarget(rows[0]?.target);
170
+ }
171
+
172
+ /** Saves the real private conversation; an enabled daily report follows it. */
173
+ async setPrivateTarget(ownerKey: string, target: DeliveryTarget): Promise<void> {
174
+ const raw = JSON.stringify(target);
175
+ const model = this.#model(PRIVATE_TARGET_TABLE);
176
+ const existing = await model.select('owner_key', 'target').where({ owner_key: ownerKey });
177
+ if (existing.length > 0) await model.update({ target: raw }).where({ owner_key: ownerKey });
178
+ else await model.insert({ owner_key: ownerKey, target: raw });
179
+ const user = await this.getUser(ownerKey);
180
+ if (user?.reportEnabled) {
181
+ await this.#model(USER_TABLE).update({ report_target: raw }).where({ owner_key: ownerKey });
182
+ }
183
+ }
184
+
185
+ async listReportSubscribers(): Promise<LedgerUserRecord[]> {
186
+ const rows = await this.#model(USER_TABLE)
187
+ .select('owner_key', 'next_seq', 'report_enabled', 'report_target', 'last_report_date')
188
+ .where({ report_enabled: 1 });
189
+ return rows.map(toUser);
190
+ }
191
+
192
+ async markReported(ownerKey: string, dateKey: string): Promise<void> {
193
+ await this.#model(USER_TABLE).update({ last_report_date: dateKey }).where({ owner_key: ownerKey });
194
+ }
195
+ }