@zhin.js/plugin-ledger 1.0.1 → 1.0.3

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 (91) hide show
  1. package/README.md +24 -0
  2. package/commands/ledger-add/index.js +6 -4
  3. package/commands/ledger-add/index.ts +6 -4
  4. package/commands/ledger-delete/index.js +6 -4
  5. package/commands/ledger-delete/index.ts +6 -4
  6. package/commands/ledger-list/index.js +8 -6
  7. package/commands/ledger-list/index.ts +8 -6
  8. package/commands/ledger-month/index.js +5 -3
  9. package/commands/ledger-month/index.ts +5 -3
  10. package/commands/ledger-report/index.js +11 -11
  11. package/commands/ledger-report/index.ts +10 -12
  12. package/commands/ledger-stats/index.js +8 -6
  13. package/commands/ledger-stats/index.ts +8 -6
  14. package/commands/ledger-today/index.js +5 -3
  15. package/commands/ledger-today/index.ts +5 -3
  16. package/commands/ledger-week/index.js +5 -3
  17. package/commands/ledger-week/index.ts +5 -3
  18. package/lib/config.d.ts +11 -5
  19. package/lib/config.d.ts.map +1 -1
  20. package/lib/config.js +3 -3
  21. package/lib/config.js.map +1 -1
  22. package/lib/console.d.ts +4 -0
  23. package/lib/console.d.ts.map +1 -0
  24. package/lib/console.js +39 -0
  25. package/lib/console.js.map +1 -0
  26. package/lib/entry-input.d.ts +3 -2
  27. package/lib/entry-input.d.ts.map +1 -1
  28. package/lib/entry-input.js +11 -9
  29. package/lib/entry-input.js.map +1 -1
  30. package/lib/i18n.d.ts +2 -0
  31. package/lib/i18n.d.ts.map +1 -0
  32. package/lib/i18n.js +200 -0
  33. package/lib/i18n.js.map +1 -0
  34. package/lib/identity.d.ts +3 -1
  35. package/lib/identity.d.ts.map +1 -1
  36. package/lib/identity.js +5 -2
  37. package/lib/identity.js.map +1 -1
  38. package/lib/index.d.ts +3 -1
  39. package/lib/index.d.ts.map +1 -1
  40. package/lib/index.js +3 -1
  41. package/lib/index.js.map +1 -1
  42. package/lib/money.d.ts +2 -1
  43. package/lib/money.d.ts.map +1 -1
  44. package/lib/money.js +6 -5
  45. package/lib/money.js.map +1 -1
  46. package/lib/period.d.ts +4 -3
  47. package/lib/period.d.ts.map +1 -1
  48. package/lib/period.js +8 -16
  49. package/lib/period.js.map +1 -1
  50. package/lib/report-format.d.ts +5 -4
  51. package/lib/report-format.d.ts.map +1 -1
  52. package/lib/report-format.js +25 -24
  53. package/lib/report-format.js.map +1 -1
  54. package/lib/service.d.ts +14 -1
  55. package/lib/service.d.ts.map +1 -1
  56. package/lib/service.js +41 -24
  57. package/lib/service.js.map +1 -1
  58. package/lib/store.d.ts +3 -1
  59. package/lib/store.d.ts.map +1 -1
  60. package/lib/store.js +8 -4
  61. package/lib/store.js.map +1 -1
  62. package/lib/tool-caller.d.ts +32 -0
  63. package/lib/tool-caller.d.ts.map +1 -0
  64. package/lib/tool-caller.js +74 -0
  65. package/lib/tool-caller.js.map +1 -0
  66. package/package.json +36 -7
  67. package/pages/index/index.js +63 -0
  68. package/pages/index/index.ts +65 -0
  69. package/plugin.js +3 -1
  70. package/schema.json +15 -0
  71. package/skills/ledger/SKILL.md +41 -0
  72. package/skills/ledger/tools/ledger_add/index.js +25 -0
  73. package/skills/ledger/tools/ledger_add/index.ts +26 -0
  74. package/skills/ledger/tools/ledger_delete/index.js +22 -0
  75. package/skills/ledger/tools/ledger_delete/index.ts +23 -0
  76. package/skills/ledger/tools/ledger_list/index.js +23 -0
  77. package/skills/ledger/tools/ledger_list/index.ts +24 -0
  78. package/skills/ledger/tools/ledger_summary/index.js +27 -0
  79. package/skills/ledger/tools/ledger_summary/index.ts +28 -0
  80. package/src/config.ts +3 -3
  81. package/src/console.ts +47 -0
  82. package/src/entry-input.ts +12 -9
  83. package/src/i18n.ts +200 -0
  84. package/src/identity.ts +6 -2
  85. package/src/index.ts +9 -1
  86. package/src/money.ts +7 -5
  87. package/src/period.ts +7 -13
  88. package/src/report-format.ts +26 -24
  89. package/src/service.ts +56 -24
  90. package/src/store.ts +9 -4
  91. package/src/tool-caller.ts +101 -0
package/src/i18n.ts ADDED
@@ -0,0 +1,200 @@
1
+ import { createMessages } from 'zhin.js/i18n';
2
+
3
+ export const messages = createMessages({
4
+ "actor": {
5
+ "zh-CN": "记账需要在真实会话中由具体用户发送",
6
+ "en": "Ledger commands require a real user in a conversation."
7
+ },
8
+ "usage": {
9
+ "zh-CN": "格式:<类别> <金额> [备注],例如:午饭 18.5 和同事",
10
+ "en": "Format: <category> <amount> [note], for example: Lunch 18.5 With colleagues"
11
+ },
12
+ "order": {
13
+ "zh-CN": "类别要写在金额前面。{usage}",
14
+ "en": "Put the category before the amount. {usage}"
15
+ },
16
+ "amountError": {
17
+ "zh-CN": "{reason}。{usage}",
18
+ "en": "{reason} {usage}"
19
+ },
20
+ "categoryNumber": {
21
+ "zh-CN": "类别不能是数字。{usage}",
22
+ "en": "The category cannot be a number. {usage}"
23
+ },
24
+ "categoryLimit": {
25
+ "zh-CN": "类别最多 {max} 个字",
26
+ "en": "Category limit: {max} characters."
27
+ },
28
+ "noteLimit": {
29
+ "zh-CN": "备注最多 {max} 个字",
30
+ "en": "Note limit: {max} characters."
31
+ },
32
+ "negative": {
33
+ "zh-CN": "金额不能为负数",
34
+ "en": "Amount cannot be negative."
35
+ },
36
+ "amountFormat": {
37
+ "zh-CN": "金额格式不正确,请写成 18 或 18.50(最多两位小数)",
38
+ "en": "Use an amount such as 18 or 18.50, with at most two decimal places."
39
+ },
40
+ "zero": {
41
+ "zh-CN": "金额必须大于 0",
42
+ "en": "Amount must be greater than zero."
43
+ },
44
+ "amountMax": {
45
+ "zh-CN": "单笔金额不能超过 ¥1000000.00",
46
+ "en": "A single entry cannot exceed ¥1000000.00."
47
+ },
48
+ "period.today": {
49
+ "zh-CN": "今日",
50
+ "en": "Today"
51
+ },
52
+ "period.week": {
53
+ "zh-CN": "本周",
54
+ "en": "This week"
55
+ },
56
+ "period.month": {
57
+ "zh-CN": "本月",
58
+ "en": "This month"
59
+ },
60
+ "period.all": {
61
+ "zh-CN": "全部",
62
+ "en": "All time"
63
+ },
64
+ "allRecords": {
65
+ "zh-CN": "全部记录",
66
+ "en": "All records"
67
+ },
68
+ "periodInput": {
69
+ "zh-CN": "时间范围只能是:今日、本周、本月、全部",
70
+ "en": "Choose a period: today, week, month, all."
71
+ },
72
+ "actionInput": {
73
+ "zh-CN": "操作只能是:开启、关闭、状态",
74
+ "en": "Choose an action: on, off, status."
75
+ },
76
+ "added": {
77
+ "zh-CN": "✅ 已记账 #{seq} · {category} {amount}{note}",
78
+ "en": "✅ Recorded #{seq} · {category} {amount}{note}"
79
+ },
80
+ "notFound": {
81
+ "zh-CN": "没有找到你的账目 #{seq}(只能删除自己的记账,请查看账单编号)",
82
+ "en": "Your entry #{seq} was not found. You can only delete your own entries; check the entry list for its ID."
83
+ },
84
+ "deleted": {
85
+ "zh-CN": "🗑️ 已删除 #{seq} · {category} {amount}",
86
+ "en": "🗑️ Deleted #{seq} · {category} {amount}"
87
+ },
88
+ "spending": {
89
+ "zh-CN": "{period}消费",
90
+ "en": "{period} spending"
91
+ },
92
+ "statistics": {
93
+ "zh-CN": "分类统计",
94
+ "en": "Category statistics"
95
+ },
96
+ "entryList": {
97
+ "zh-CN": "{period}账单",
98
+ "en": "{period} entries"
99
+ },
100
+ "privateSent": {
101
+ "zh-CN": "🔒 {title}已私信发送给你",
102
+ "en": "🔒 {title} was sent to you privately."
103
+ },
104
+ "privateFallback": {
105
+ "zh-CN": "{text}\n(群聊中金额已隐藏,私聊我可查看完整金额)",
106
+ "en": "{text}\n(Amounts are hidden in public chats. Message me privately to view them.)"
107
+ },
108
+ "reportEnabled": {
109
+ "zh-CN": "记账日报已开启:每天 {time}({timezone})私信发送当天消费",
110
+ "en": "Daily reports are enabled: today’s spending is sent privately at {time} ({timezone})."
111
+ },
112
+ "reportDisabled": {
113
+ "zh-CN": "记账日报未开启。",
114
+ "en": "Daily reports are disabled."
115
+ },
116
+ "reportOff": {
117
+ "zh-CN": "已关闭记账日报",
118
+ "en": "Daily reports disabled."
119
+ },
120
+ "noPrivate": {
121
+ "zh-CN": "当前环境不支持主动私信,无法开启记账日报",
122
+ "en": "This environment cannot send proactive private messages; daily reports cannot be enabled."
123
+ },
124
+ "reportOn": {
125
+ "zh-CN": "已开启记账日报:每天 {time}({timezone})私信发送当天消费。",
126
+ "en": "Daily reports enabled: today’s spending is sent privately at {time} ({timezone})."
127
+ },
128
+ "cannotPrivate": {
129
+ "zh-CN": "无法私信你,请先私聊我后再开启记账日报。",
130
+ "en": "I could not message you privately. Message me privately before enabling daily reports."
131
+ },
132
+ "reportOnPrivate": {
133
+ "zh-CN": "🔒 已开启记账日报,确认消息已私信发送给你",
134
+ "en": "🔒 Daily reports enabled; confirmation was sent to you privately."
135
+ },
136
+ "report": {
137
+ "zh-CN": "🌙 记账日报\n{text}",
138
+ "en": "🌙 Daily expense report\n{text}"
139
+ },
140
+ "commandHint": {
141
+ "zh-CN": "发送「{command}」。",
142
+ "en": "Send \"{command}\"."
143
+ },
144
+ "emptySummary": {
145
+ "zh-CN": "📒 {period}还没有记账({range})",
146
+ "en": "📒 {period}: no expenses recorded ({range})."
147
+ },
148
+ "summaryHeader": {
149
+ "zh-CN": "📒 {period}消费({range})",
150
+ "en": "📒 {period} spending ({range})"
151
+ },
152
+ "total": {
153
+ "zh-CN": "合计 {amount} · {count} 笔",
154
+ "en": "Total {amount} · {count} entries"
155
+ },
156
+ "categoryFull": {
157
+ "zh-CN": "· {category} {amount}({count} 笔,{share}%)",
158
+ "en": "· {category} {amount} ({count} entries, {share}%)"
159
+ },
160
+ "categoryMasked": {
161
+ "zh-CN": "· {category} {amount}({count} 笔)",
162
+ "en": "· {category} {amount} ({count} entries)"
163
+ },
164
+ "emptyStats": {
165
+ "zh-CN": "📊 {period}没有可统计的记账({range})",
166
+ "en": "📊 {period}: no entries to analyze ({range})."
167
+ },
168
+ "statsHeader": {
169
+ "zh-CN": "📊 分类统计 · {period}({range})",
170
+ "en": "📊 Category statistics · {period} ({range})"
171
+ },
172
+ "statsFull": {
173
+ "zh-CN": "· {category}:合计 {amount},{count} 笔,平均 {average},最大 {max},占比 {share}%",
174
+ "en": "· {category}: total {amount}, {count} entries, average {average}, largest {max}, share {share}%"
175
+ },
176
+ "statsMasked": {
177
+ "zh-CN": "· {category}:{count} 笔,金额已隐藏",
178
+ "en": "· {category}: {count} entries, amounts hidden"
179
+ },
180
+ "categoryCount": {
181
+ "zh-CN": "共 {count} 个类别",
182
+ "en": "{count} categories"
183
+ },
184
+ "categoryCountTotal": {
185
+ "zh-CN": "共 {count} 个类别 · 合计 {amount}",
186
+ "en": "{count} categories · total {amount}"
187
+ },
188
+ "emptyList": {
189
+ "zh-CN": "🧾 {period}没有账目({range})",
190
+ "en": "🧾 {period}: no entries ({range})."
191
+ },
192
+ "listHeader": {
193
+ "zh-CN": "🧾 {period}账单({range})",
194
+ "en": "🧾 {period} entries ({range})"
195
+ },
196
+ "more": {
197
+ "zh-CN": "……另有 {count} 笔未显示",
198
+ "en": "… {count} more entries not shown"
199
+ }
200
+ });
package/src/identity.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolveLocale, type Locale } from 'zhin.js/i18n';
2
+ import { messages as t } from './i18n.js';
1
3
  import { createDeliveryTarget, type DeliveryTarget } from 'zhin.js';
2
4
  import type { CommandMessage } from 'zhin.js/command';
3
5
 
@@ -5,6 +7,7 @@ import type { CommandMessage } from 'zhin.js/command';
5
7
  export interface LedgerCaller {
6
8
  /** Stable per-user key: adapter + endpoint (bot account) + sender id. */
7
9
  readonly ownerKey: string;
10
+ readonly locale?: Locale;
8
11
  readonly senderId: string;
9
12
  /** The conversation the command came from. */
10
13
  readonly conversation: DeliveryTarget;
@@ -21,9 +24,9 @@ export function ledgerOwnerKey(adapter: string, endpointId: string, senderId: st
21
24
  }
22
25
 
23
26
  /** Builds the caller from a command message; commands without a real sender are refused. */
24
- export function ledgerCaller(input: CommandMessage | undefined): LedgerCaller {
27
+ export function ledgerCaller(input: CommandMessage | undefined, locale: Locale = resolveLocale(input?.locale)): LedgerCaller {
25
28
  const senderId = input?.sender?.id;
26
- if (!input || !senderId) throw new LedgerInputError('记账需要在真实会话中由具体用户发送');
29
+ if (!input || !senderId) throw new LedgerInputError(t(locale, 'actor'));
27
30
  const conversation = createDeliveryTarget(input);
28
31
  const endpoint = conversation.endpoint;
29
32
  const isPrivate = conversation.kind === 'private';
@@ -33,6 +36,7 @@ export function ledgerCaller(input: CommandMessage | undefined): LedgerCaller {
33
36
  return {
34
37
  ownerKey: ledgerOwnerKey(endpoint.adapter, endpoint.id, senderId),
35
38
  senderId,
39
+ locale,
36
40
  conversation,
37
41
  privateTarget,
38
42
  isPrivate,
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { ledgerConfig, resolveLedgerConfig, DEFAULT_REPORT_TIME, DEFAULT_TIMEZONE, type LedgerConfig, type ResolvedLedgerConfig } from './config.js';
2
+ export { createLedgerConsoleRpc } from './console.js';
2
3
  export { parseEntryInput, ADD_USAGE, MAX_CATEGORY_LENGTH, MAX_NOTE_LENGTH, type EntryInputResult, type ParsedEntryInput } from './entry-input.js';
3
4
  export { ledgerCaller, ledgerOwnerKey, replyLedger, LedgerInputError, type LedgerCaller } from './identity.js';
4
5
  export { formatCents, parseAmountToCents, MASKED_AMOUNT, MAX_ENTRY_CENTS, type AmountParseResult } from './money.js';
@@ -25,5 +26,12 @@ export {
25
26
  type PeriodSummary,
26
27
  } from './report-format.js';
27
28
  export { ledgerServiceToken } from './runtime.js';
28
- export { LedgerService, type DailyReportAction, type LedgerServiceOptions } from './service.js';
29
+ export {
30
+ LedgerService,
31
+ type DailyReportAction,
32
+ type LedgerConsoleSnapshot,
33
+ type LedgerConsoleSubscriber,
34
+ type LedgerServiceOptions,
35
+ } from './service.js';
29
36
  export { defineLedgerTables, LedgerStore, ENTRY_TABLE, USER_TABLE, PRIVATE_TARGET_TABLE, type LedgerEntry, type LedgerUserRecord } from './store.js';
37
+ export { ledgerCallerFromTool, type LedgerToolCallerContext } from './tool-caller.js';
package/src/money.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolveLocale, type Locale } from 'zhin.js/i18n';
2
+ import { messages as t } from './i18n.js';
1
3
  /**
2
4
  * Amounts are stored as integer cents (分) and parsed from text without floating point,
3
5
  * so 0.1 + 0.2 style rounding errors cannot enter the ledger.
@@ -28,17 +30,17 @@ function invalid(reason: string): AmountParseResult {
28
30
  }
29
31
 
30
32
  /** Parses `18`, `18.5`, `18.50`, `¥18.5` or `18.5元` into cents; rejects negatives, zero, exponents and more than two decimals. */
31
- export function parseAmountToCents(raw: string): AmountParseResult {
33
+ export function parseAmountToCents(raw: string, locale?: Locale): AmountParseResult {
32
34
  const normalized = toHalfWidth(raw.trim());
33
35
  const withoutCurrency = normalized.replace(CURRENCY_PREFIX, '').replace(CURRENCY_SUFFIX, '').trim();
34
- if (withoutCurrency.startsWith('-')) return invalid('金额不能为负数');
36
+ if (withoutCurrency.startsWith('-')) return invalid(t(locale, 'negative'));
35
37
  const match = AMOUNT_PATTERN.exec(withoutCurrency);
36
- if (!match) return invalid('金额格式不正确,请写成 18 或 18.50(最多两位小数)');
38
+ if (!match) return invalid(t(locale, 'amountFormat'));
37
39
  const wholeYuan = Number(match[1]);
38
40
  const fractionDigits = (match[2] ?? '').padEnd(2, '0');
39
41
  const cents = wholeYuan * 100 + Number(fractionDigits);
40
- if (cents === 0) return invalid('金额必须大于 0');
41
- if (cents > MAX_ENTRY_CENTS) return invalid('单笔金额不能超过 ¥1000000.00');
42
+ if (cents === 0) return invalid(t(locale, 'zero'));
43
+ if (cents > MAX_ENTRY_CENTS) return invalid(t(locale, 'amountMax'));
42
44
  return { ok: true, cents };
43
45
  }
44
46
 
package/src/period.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolveLocale, type Locale } from 'zhin.js/i18n';
2
+ import { messages as t } from './i18n.js';
1
3
  /**
2
4
  * Calendar periods evaluated in the configured IANA timezone. Entries are compared by their
3
5
  * local calendar date (YYYY-MM-DD), so daylight-saving shifts never move an entry into another day.
@@ -7,21 +9,13 @@ export type LedgerPeriod = 'today' | 'week' | 'month' | 'all';
7
9
 
8
10
  const DAY_MS = 86_400_000;
9
11
 
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
- }
12
+ export function periodLabel(period: LedgerPeriod, locale?: Locale): string { return t(locale, `period.${period}`); }
20
13
 
21
14
  /** 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 {
15
+ export function parsePeriodWord(word: string | undefined, fallback: LedgerPeriod, locale?: Locale): LedgerPeriod | undefined {
23
16
  if (word === undefined || word.trim() === '') return fallback;
24
17
  const normalized = word.trim();
18
+ if (resolveLocale(locale) === 'en') return ['today', 'week', 'month', 'all'].includes(normalized.toLowerCase()) ? normalized.toLowerCase() as LedgerPeriod : undefined;
25
19
  if (normalized === '今日' || normalized === '今天') return 'today';
26
20
  if (normalized === '本周' || normalized === '这周') return 'week';
27
21
  if (normalized === '本月' || normalized === '这个月') return 'month';
@@ -89,7 +83,7 @@ export function isInPeriod(entryMs: number, nowMs: number, period: LedgerPeriod,
89
83
  }
90
84
 
91
85
  /** 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 {
86
+ export function describePeriodRange(nowMs: number, period: LedgerPeriod, timeZone: string, locale?: Locale): string {
93
87
  const today = localDateKey(nowMs, timeZone);
94
88
  if (period === 'today') return today;
95
89
  if (period === 'month') return today.slice(0, 7);
@@ -97,5 +91,5 @@ export function describePeriodRange(nowMs: number, period: LedgerPeriod, timeZon
97
91
  const start = weekStartKey(today);
98
92
  return `${start} ~ ${weekEndKey(start)}`;
99
93
  }
100
- return '全部记录';
94
+ return t(locale, 'allRecords');
101
95
  }
@@ -1,3 +1,5 @@
1
+ import { resolveLocale, type Locale } from 'zhin.js/i18n';
2
+ import { messages as t } from './i18n.js';
1
3
  import { formatCents, MASKED_AMOUNT } from './money.js';
2
4
  import { describePeriodRange, localDateKey, periodLabel, type LedgerPeriod } from './period.js';
3
5
  import type { LedgerEntry } from './store.js';
@@ -39,12 +41,12 @@ export function totalsByCategory(entries: readonly LedgerEntry[]): CategoryTotal
39
41
  return totals;
40
42
  }
41
43
 
42
- export function summarize(entries: readonly LedgerEntry[], period: LedgerPeriod, nowMs: number, timeZone: string): PeriodSummary {
44
+ export function summarize(entries: readonly LedgerEntry[], period: LedgerPeriod, nowMs: number, timeZone: string, locale?: Locale): PeriodSummary {
43
45
  let totalCents = 0;
44
46
  for (const entry of entries) totalCents += entry.amountCents;
45
47
  return {
46
48
  period,
47
- range: describePeriodRange(nowMs, period, timeZone),
49
+ range: describePeriodRange(nowMs, period, timeZone, locale),
48
50
  totalCents,
49
51
  count: entries.length,
50
52
  categories: totalsByCategory(entries),
@@ -58,56 +60,56 @@ function sharePercent(partCents: number, totalCents: number): string {
58
60
  return `${Math.floor(tenths / 10)}.${tenths % 10}`;
59
61
  }
60
62
 
61
- export function formatPeriodSummary(summary: PeriodSummary): LedgerReply {
62
- const label = periodLabel(summary.period);
63
+ export function formatPeriodSummary(summary: PeriodSummary, locale?: Locale): LedgerReply {
64
+ const label = periodLabel(summary.period, locale);
63
65
  if (summary.count === 0) {
64
- const empty = `📒 ${label}还没有记账(${summary.range})`;
66
+ const empty = t(locale, 'emptySummary', { period: label, range: summary.range });
65
67
  return { full: empty, masked: empty };
66
68
  }
67
- const header = `📒 ${label}消费(${summary.range})`;
68
- const fullLines = [header, `合计 ${formatCents(summary.totalCents)} · ${summary.count} 笔`];
69
- const maskedLines = [header, `合计 ${MASKED_AMOUNT} · ${summary.count} 笔`];
69
+ const header = t(locale, 'summaryHeader', { period: label, range: summary.range });
70
+ const fullLines = [header, t(locale, 'total', { amount: formatCents(summary.totalCents), count: summary.count })];
71
+ const maskedLines = [header, t(locale, 'total', { amount: MASKED_AMOUNT, count: summary.count })];
70
72
  for (const category of summary.categories) {
71
73
  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
+ fullLines.push(t(locale, 'categoryFull', { category: category.category, amount: formatCents(category.totalCents), count: category.count, share }));
75
+ maskedLines.push(t(locale, 'categoryMasked', { category: category.category, amount: MASKED_AMOUNT, count: category.count }));
74
76
  }
75
77
  return { full: fullLines.join('\n'), masked: maskedLines.join('\n') };
76
78
  }
77
79
 
78
- export function formatCategoryStatistics(summary: PeriodSummary): LedgerReply {
79
- const label = periodLabel(summary.period);
80
+ export function formatCategoryStatistics(summary: PeriodSummary, locale?: Locale): LedgerReply {
81
+ const label = periodLabel(summary.period, locale);
80
82
  if (summary.count === 0) {
81
- const empty = `📊 ${label}没有可统计的记账(${summary.range})`;
83
+ const empty = t(locale, 'emptyStats', { period: label, range: summary.range });
82
84
  return { full: empty, masked: empty };
83
85
  }
84
- const header = `📊 分类统计 · ${label}(${summary.range})`;
86
+ const header = t(locale, 'statsHeader', { period: label, range: summary.range });
85
87
  const fullLines = [header];
86
88
  const maskedLines = [header];
87
89
  for (const category of summary.categories) {
88
90
  const averageCents = Math.round(category.totalCents / category.count);
89
91
  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
+ fullLines.push(t(locale, 'statsFull', { category: category.category, amount: formatCents(category.totalCents), count: category.count, average: formatCents(averageCents), max: formatCents(category.largestCents), share }));
93
+ maskedLines.push(t(locale, 'statsMasked', { category: category.category, count: category.count }));
92
94
  }
93
- fullLines.push(`共 ${summary.categories.length} 个类别 · 合计 ${formatCents(summary.totalCents)}`);
94
- maskedLines.push(`共 ${summary.categories.length} 个类别`);
95
+ fullLines.push(t(locale, 'categoryCountTotal', { count: summary.categories.length, amount: formatCents(summary.totalCents) }));
96
+ maskedLines.push(t(locale, 'categoryCount', { count: summary.categories.length }));
95
97
  return { full: fullLines.join('\n'), masked: maskedLines.join('\n') };
96
98
  }
97
99
 
98
100
  export const MAX_LISTED_ENTRIES = 20;
99
101
 
100
102
  /** 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);
103
+ export function formatEntryList(entries: readonly LedgerEntry[], period: LedgerPeriod, nowMs: number, timeZone: string, locale?: Locale): LedgerReply {
104
+ const label = periodLabel(period, locale);
105
+ const range = describePeriodRange(nowMs, period, timeZone, locale);
104
106
  if (entries.length === 0) {
105
- const empty = `🧾 ${label}没有账目(${range})`;
107
+ const empty = t(locale, 'emptyList', { period: label, range });
106
108
  return { full: empty, masked: empty };
107
109
  }
108
110
  const newestFirst = [...entries].sort((left, right) => right.createdAt - left.createdAt || right.seq - left.seq);
109
111
  const shown = newestFirst.slice(0, MAX_LISTED_ENTRIES);
110
- const header = `🧾 ${label}账单(${range})`;
112
+ const header = t(locale, 'listHeader', { period: label, range });
111
113
  const fullLines = [header];
112
114
  const maskedLines = [header];
113
115
  for (const entry of shown) {
@@ -117,7 +119,7 @@ export function formatEntryList(entries: readonly LedgerEntry[], period: LedgerP
117
119
  maskedLines.push(`#${entry.seq} ${date} ${entry.category} ${MASKED_AMOUNT}`);
118
120
  }
119
121
  if (newestFirst.length > shown.length) {
120
- const more = `……另有 ${newestFirst.length - shown.length} 笔未显示`;
122
+ const more = t(locale, 'more', { count: newestFirst.length - shown.length });
121
123
  fullLines.push(more);
122
124
  maskedLines.push(more);
123
125
  }
package/src/service.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolveLocale, type Locale } from 'zhin.js/i18n';
2
+ import { messages as t } from './i18n.js';
1
3
  import { getLogger, type DeliveryTarget, type OutboundHost, type PluginDatabaseHost } from 'zhin.js';
2
4
  import type { ResolvedLedgerConfig } from './config.js';
3
5
  import { parseEntryInput } from './entry-input.js';
@@ -17,6 +19,20 @@ const logger = getLogger('ledger');
17
19
 
18
20
  export type DailyReportAction = 'on' | 'off' | 'status';
19
21
 
22
+ export type LedgerConsoleSubscriber = {
23
+ readonly ownerKey: string;
24
+ readonly reportLocale: string;
25
+ readonly lastReportDate: string;
26
+ };
27
+
28
+ export type LedgerConsoleSnapshot = {
29
+ readonly timezone: string;
30
+ readonly reportTime: string;
31
+ readonly reportWhenEmpty: boolean;
32
+ readonly subscriberCount: number;
33
+ readonly subscribers: readonly LedgerConsoleSubscriber[];
34
+ };
35
+
20
36
  export interface LedgerServiceOptions {
21
37
  /** Wall clock; tests pass a fake. */
22
38
  readonly now?: () => number;
@@ -81,7 +97,7 @@ export class LedgerService {
81
97
 
82
98
  async addEntry(caller: LedgerCaller, text: string): Promise<string> {
83
99
  await this.#rememberPrivate(caller);
84
- const parsed = parseEntryInput(text);
100
+ const parsed = parseEntryInput(text, caller.locale);
85
101
  if (!parsed.ok) return parsed.reason;
86
102
  const { category, amountCents, note } = parsed.entry;
87
103
  const seq = await this.#withUserLock(caller.ownerKey, async () => {
@@ -98,17 +114,17 @@ export class LedgerService {
98
114
  });
99
115
  const amount = caller.isPrivate ? formatCents(amountCents) : MASKED_AMOUNT;
100
116
  const noteText = note ? ` · ${note}` : '';
101
- return `✅ 已记账 #${seq} · ${category} ${amount}${noteText}`;
117
+ return t(caller.locale, 'added', { seq, category, amount, note: noteText });
102
118
  }
103
119
 
104
120
  async deleteEntry(caller: LedgerCaller, seq: number): Promise<string> {
105
121
  await this.#rememberPrivate(caller);
106
122
  return this.#withUserLock(caller.ownerKey, async () => {
107
123
  const entry = await this.#store.findEntry(caller.ownerKey, seq);
108
- if (!entry) return `没有找到你的账目 #${seq}(只能删除自己的记账,可用「账单」查看编号)`;
124
+ if (!entry) return t(caller.locale, 'notFound', { seq });
109
125
  await this.#store.deleteEntry(caller.ownerKey, seq);
110
126
  const amount = caller.isPrivate ? formatCents(entry.amountCents) : MASKED_AMOUNT;
111
- return `🗑️ 已删除 #${seq} · ${entry.category} ${amount}`;
127
+ return t(caller.locale, 'deleted', { seq, category: entry.category, amount });
112
128
  });
113
129
  }
114
130
 
@@ -120,20 +136,20 @@ export class LedgerService {
120
136
 
121
137
  async periodSummary(caller: LedgerCaller, period: LedgerPeriod): Promise<string> {
122
138
  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)}消费`);
139
+ const reply = formatPeriodSummary(summarize(entries, period, this.#now(), this.#config.timezone, caller.locale), caller.locale);
140
+ return this.#deliver(caller, reply, t(caller.locale, 'spending', { period: periodLabel(period, caller.locale) }));
125
141
  }
126
142
 
127
143
  async categoryStatistics(caller: LedgerCaller, period: LedgerPeriod): Promise<string> {
128
144
  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, '分类统计');
145
+ const reply = formatCategoryStatistics(summarize(entries, period, this.#now(), this.#config.timezone, caller.locale), caller.locale);
146
+ return this.#deliver(caller, reply, t(caller.locale, 'statistics'));
131
147
  }
132
148
 
133
149
  async listEntries(caller: LedgerCaller, period: LedgerPeriod): Promise<string> {
134
150
  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)}账单`);
151
+ const reply = formatEntryList(entries, period, this.#now(), this.#config.timezone, caller.locale);
152
+ return this.#deliver(caller, reply, t(caller.locale, 'entryList', { period: periodLabel(period, caller.locale) }));
137
153
  }
138
154
 
139
155
  /** Private chat: full reply. Group/channel: try a private message, else the masked reply. */
@@ -144,15 +160,15 @@ export class LedgerService {
144
160
  }
145
161
  if (reply.full === reply.masked) return reply.full;
146
162
  const delivered = await this.#sendPrivately(caller, reply.full);
147
- if (delivered) return `🔒 ${title}已私信发送给你`;
148
- return `${reply.masked}\n(群聊中金额已隐藏,私聊我可查看完整金额)`;
163
+ if (delivered) return t(caller.locale, 'privateSent', { title });
164
+ return t(caller.locale, 'privateFallback', { text: reply.masked });
149
165
  }
150
166
 
151
167
  async #sendPrivately(caller: LedgerCaller, content: string): Promise<boolean> {
152
168
  const sendTarget = this.#outbound?.sendTarget;
153
169
  if (!sendTarget) return false;
154
170
  try {
155
- await sendTarget.call(this.#outbound, await this.#privateTargetOf(caller), content);
171
+ await sendTarget.call(this.#outbound, await this.#privateTargetOf(caller), content, { locale: resolveLocale(caller.locale) });
156
172
  return true;
157
173
  } catch (error) {
158
174
  logger.debug(`private delivery unavailable: ${error instanceof Error ? error.message : String(error)}`);
@@ -160,30 +176,31 @@ export class LedgerService {
160
176
  }
161
177
  }
162
178
 
163
- async dailyReport(caller: LedgerCaller, action: DailyReportAction): Promise<string> {
179
+ async dailyReport(caller: LedgerCaller, action: DailyReportAction, commandHead?: string): Promise<string> {
164
180
  await this.#rememberPrivate(caller);
181
+ const hint = (action: 'on' | 'off') => commandHead ? t(caller.locale, 'commandHint', { command: `${commandHead} ${resolveLocale(caller.locale) === 'en' ? action : action === 'on' ? '开启' : '关闭'}` }) : '';
165
182
  const reportTime = this.#config.reportTime;
166
183
  const user = await this.#store.getUser(caller.ownerKey);
167
184
  if (action === 'status') {
168
- if (user?.reportEnabled) return `记账日报已开启:每天 ${reportTime}(${this.#config.timezone})私信发送当天消费`;
169
- return '记账日报未开启。发送「记账日报 开启」订阅';
185
+ if (user?.reportEnabled) return t(caller.locale, 'reportEnabled', { time: reportTime, timezone: this.#config.timezone });
186
+ return [t(caller.locale, 'reportDisabled'), hint('on')].filter(Boolean).join(' ');
170
187
  }
171
188
  if (action === 'off') {
172
189
  await this.#store.setReport(caller.ownerKey, false, undefined, user?.lastReportDate ?? '');
173
- return '已关闭记账日报';
190
+ return t(caller.locale, 'reportOff');
174
191
  }
175
- if (!this.#outbound?.sendTarget) return '当前环境不支持主动私信,无法开启记账日报';
176
- const confirmation = `已开启记账日报:每天 ${reportTime}(${this.#config.timezone})私信发送当天消费。发送「记账日报 关闭」取消`;
192
+ if (!this.#outbound?.sendTarget) return t(caller.locale, 'noPrivate');
193
+ const confirmation = [t(caller.locale, 'reportOn', { time: reportTime, timezone: this.#config.timezone }), hint('off')].filter(Boolean).join(' ');
177
194
  if (!caller.isPrivate) {
178
195
  const reachable = await this.#sendPrivately(caller, confirmation);
179
- if (!reachable) return '无法私信你,请先私聊我后再发送「记账日报 开启」';
196
+ if (!reachable) return [t(caller.locale, 'cannotPrivate'), hint('on')].filter(Boolean).join(' ');
180
197
  }
181
198
  const nowMs = this.#now();
182
199
  const today = localDateKey(nowMs, this.#config.timezone);
183
200
  const alreadyPastReportTime = localTimeKey(nowMs, this.#config.timezone) >= reportTime;
184
201
  const lastReportDate = alreadyPastReportTime ? today : '';
185
- await this.#store.setReport(caller.ownerKey, true, await this.#privateTargetOf(caller), lastReportDate);
186
- return caller.isPrivate ? confirmation : '🔒 已开启记账日报,确认消息已私信发送给你';
202
+ await this.#store.setReport(caller.ownerKey, true, await this.#privateTargetOf(caller), lastReportDate, resolveLocale(caller.locale));
203
+ return caller.isPrivate ? confirmation : t(caller.locale, 'reportOnPrivate');
187
204
  }
188
205
 
189
206
  /**
@@ -204,9 +221,9 @@ export class LedgerService {
204
221
  if (!subscriber.reportTarget || !this.#outbound?.sendTarget) continue;
205
222
  const entries = await this.#entriesIn(subscriber.ownerKey, 'today');
206
223
  if (entries.length === 0 && !this.#config.reportWhenEmpty) continue;
207
- const summary = formatPeriodSummary(summarize(entries, 'today', nowMs, timezone));
224
+ const summary = formatPeriodSummary(summarize(entries, 'today', nowMs, timezone, subscriber.reportLocale), subscriber.reportLocale);
208
225
  try {
209
- await this.#outbound.sendTarget(subscriber.reportTarget, `🌙 记账日报\n${summary.full}`);
226
+ await this.#outbound.sendTarget(subscriber.reportTarget, t(subscriber.reportLocale, 'report', { text: summary.full }), { locale: resolveLocale(subscriber.reportLocale) });
210
227
  sent += 1;
211
228
  } catch (error) {
212
229
  logger.warn(`daily report delivery failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -214,4 +231,19 @@ export class LedgerService {
214
231
  }
215
232
  return sent;
216
233
  }
234
+
235
+ async getConsoleSnapshot(): Promise<LedgerConsoleSnapshot> {
236
+ const subscribers = await this.#store.listReportSubscribers();
237
+ return {
238
+ timezone: this.#config.timezone,
239
+ reportTime: this.#config.reportTime,
240
+ reportWhenEmpty: this.#config.reportWhenEmpty,
241
+ subscriberCount: subscribers.length,
242
+ subscribers: subscribers.map((subscriber) => ({
243
+ ownerKey: subscriber.ownerKey,
244
+ reportLocale: resolveLocale(subscriber.reportLocale),
245
+ lastReportDate: subscriber.lastReportDate,
246
+ })),
247
+ };
248
+ }
217
249
  }