mioku-plugin-deerpipe 1.0.0 → 1.1.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 (3) hide show
  1. package/db.ts +141 -105
  2. package/image.ts +23 -4
  3. package/package.json +33 -1
package/db.ts CHANGED
@@ -1,34 +1,22 @@
1
- import { createDB } from "mioki";
2
- import { ensureDataDir } from "mioku";
1
+ import { Database } from "bun:sqlite";
3
2
  import * as path from "path";
3
+ import { ensureDataDir } from "mioku";
4
4
  import type {
5
5
  DeerCheckInResult,
6
6
  DeerRankEntry,
7
7
  DeerUser,
8
8
  } from "./types";
9
9
 
10
- interface UserRecord {
11
- canBeHelped: boolean;
12
- noDeerUntil: number | null;
10
+ interface UserRow {
11
+ can_be_helped: number;
12
+ no_deer_until: number | null;
13
13
  }
14
14
 
15
- /**
16
- * users: key = "<scene>:<userId>"
17
- * records: scene -> "yyyy-mm" -> userId -> day -> count
18
- */
19
- interface DeerStore {
20
- users: Record<string, UserRecord>;
21
- records: Record<
22
- string,
23
- Record<string, Record<string, Record<string, number>>>
24
- >;
15
+ interface RecordRow {
16
+ day: number;
17
+ count: number;
25
18
  }
26
19
 
27
- const DEFAULT_STORE: DeerStore = {
28
- users: {},
29
- records: {},
30
- };
31
-
32
20
  export interface DeerDatabase {
33
21
  getOrCreateUser(scene: string, userId: number): DeerUser;
34
22
  updateUser(user: DeerUser): Promise<void>;
@@ -53,6 +41,7 @@ export interface DeerDatabase {
53
41
  limit: number,
54
42
  ): DeerRankEntry[];
55
43
  cleanupOtherMonths(year: number, month: number): Promise<void>;
44
+ close(): void;
56
45
  }
57
46
 
58
47
  function userKey(scene: string, userId: number): string {
@@ -65,38 +54,74 @@ function monthKey(year: number, month: number): string {
65
54
 
66
55
  export async function initDeerDatabase(): Promise<DeerDatabase> {
67
56
  const dir = ensureDataDir("deerpipe");
68
- const file = path.join(dir, "deerpipe.json");
69
- const db = await createDB<DeerStore>(file, {
70
- defaultData: structuredClone(DEFAULT_STORE),
71
- });
72
-
73
- // Defensive: createDB merges with defaultData but keys may have been
74
- // dropped from a hand-edited file.
75
- if (!db.data.users) db.data.users = {};
76
- if (!db.data.records) db.data.records = {};
77
-
78
- function readUserRecord(scene: string, userId: number): UserRecord {
79
- const key = userKey(scene, userId);
80
- let row = db.data.users[key];
81
- if (!row) {
82
- row = { canBeHelped: true, noDeerUntil: null };
83
- db.data.users[key] = row;
84
- }
85
- return row;
86
- }
87
-
88
- function readMonthRecords(
89
- scene: string,
90
- userId: number,
91
- year: number,
92
- month: number,
93
- ): Record<string, number> {
94
- const sceneRecords = db.data.records[scene];
95
- if (!sceneRecords) return {};
96
- const monthRecords = sceneRecords[monthKey(year, month)];
97
- if (!monthRecords) return {};
98
- return monthRecords[String(userId)] ?? {};
99
- }
57
+ const dbPath = path.join(dir, "deerpipe.db");
58
+ const db = new Database(dbPath);
59
+
60
+ db.exec("PRAGMA journal_mode = WAL");
61
+
62
+ db.exec(`
63
+ CREATE TABLE IF NOT EXISTS users (
64
+ key TEXT PRIMARY KEY,
65
+ can_be_helped INTEGER NOT NULL DEFAULT 1,
66
+ no_deer_until INTEGER
67
+ );
68
+
69
+ CREATE TABLE IF NOT EXISTS records (
70
+ scene TEXT NOT NULL,
71
+ month_key TEXT NOT NULL,
72
+ user_id INTEGER NOT NULL,
73
+ day INTEGER NOT NULL,
74
+ count INTEGER NOT NULL DEFAULT 0,
75
+ PRIMARY KEY (scene, month_key, user_id, day)
76
+ );
77
+ CREATE INDEX IF NOT EXISTS idx_records_scene_month
78
+ ON records(scene, month_key);
79
+ `);
80
+
81
+ const stmts = {
82
+ getUser: db.prepare(
83
+ `SELECT can_be_helped, no_deer_until FROM users WHERE key = $key`,
84
+ ),
85
+ insertUser: db.prepare(`
86
+ INSERT INTO users (key, can_be_helped, no_deer_until)
87
+ VALUES ($key, 1, NULL)
88
+ ON CONFLICT(key) DO NOTHING
89
+ `),
90
+ updateUser: db.prepare(`
91
+ INSERT INTO users (key, can_be_helped, no_deer_until)
92
+ VALUES ($key, $canBeHelped, $noDeerUntil)
93
+ ON CONFLICT(key) DO UPDATE SET
94
+ can_be_helped = $canBeHelped,
95
+ no_deer_until = $noDeerUntil
96
+ `),
97
+ getMonthRecords: db.prepare(`
98
+ SELECT day, count FROM records
99
+ WHERE scene = $scene AND month_key = $monthKey AND user_id = $userId
100
+ ORDER BY day ASC
101
+ `),
102
+ getDayRecord: db.prepare(`
103
+ SELECT count FROM records
104
+ WHERE scene = $scene AND month_key = $monthKey AND user_id = $userId AND day = $day
105
+ `),
106
+ insertRecord: db.prepare(`
107
+ INSERT INTO records (scene, month_key, user_id, day, count)
108
+ VALUES ($scene, $monthKey, $userId, $day, 1)
109
+ `),
110
+ incrementRecord: db.prepare(`
111
+ UPDATE records SET count = count + 1
112
+ WHERE scene = $scene AND month_key = $monthKey AND user_id = $userId AND day = $day
113
+ `),
114
+ getMonthRank: db.prepare(`
115
+ SELECT user_id, SUM(count) AS total FROM records
116
+ WHERE scene = $scene AND month_key = $monthKey
117
+ GROUP BY user_id
118
+ ORDER BY total DESC
119
+ LIMIT $limit
120
+ `),
121
+ deleteOtherMonths: db.prepare(
122
+ `DELETE FROM records WHERE month_key != $keepKey`,
123
+ ),
124
+ };
100
125
 
101
126
  function loadRecords(
102
127
  scene: string,
@@ -104,30 +129,40 @@ export async function initDeerDatabase(): Promise<DeerDatabase> {
104
129
  year: number,
105
130
  month: number,
106
131
  ): Map<number, number> {
107
- const raw = readMonthRecords(scene, userId, year, month);
132
+ const rows = stmts.getMonthRecords.all({
133
+ $scene: scene,
134
+ $monthKey: monthKey(year, month),
135
+ $userId: userId,
136
+ }) as RecordRow[];
108
137
  const map = new Map<number, number>();
109
- for (const [k, v] of Object.entries(raw)) {
110
- map.set(Number(k), Number(v));
138
+ for (const row of rows) {
139
+ map.set(row.day, row.count);
111
140
  }
112
141
  return map;
113
142
  }
114
143
 
115
144
  return {
116
145
  getOrCreateUser(scene, userId) {
117
- const row = readUserRecord(scene, userId);
146
+ const key = userKey(scene, userId);
147
+ let row = stmts.getUser.get({ $key: key }) as UserRow | null;
148
+ if (!row) {
149
+ stmts.insertUser.run({ $key: key });
150
+ row = { can_be_helped: 1, no_deer_until: null };
151
+ }
118
152
  return {
119
153
  scene,
120
154
  userId,
121
- canBeHelped: row.canBeHelped,
122
- noDeerUntil: row.noDeerUntil,
155
+ canBeHelped: Boolean(row.can_be_helped),
156
+ noDeerUntil: row.no_deer_until,
123
157
  };
124
158
  },
125
159
 
126
160
  async updateUser(user) {
127
- const row = readUserRecord(user.scene, user.userId);
128
- row.canBeHelped = user.canBeHelped;
129
- row.noDeerUntil = user.noDeerUntil;
130
- await db.write();
161
+ stmts.updateUser.run({
162
+ $key: userKey(user.scene, user.userId),
163
+ $canBeHelped: user.canBeHelped ? 1 : 0,
164
+ $noDeerUntil: user.noDeerUntil,
165
+ });
131
166
  },
132
167
 
133
168
  getRecords(scene, userId, year, month) {
@@ -135,25 +170,39 @@ export async function initDeerDatabase(): Promise<DeerDatabase> {
135
170
  },
136
171
 
137
172
  async checkIn(scene, userId, year, month, day, isPast) {
138
- readUserRecord(scene, userId);
139
- const sceneRecords = (db.data.records[scene] ??= {});
140
- const monthRecords = (sceneRecords[monthKey(year, month)] ??= {});
141
- const userRecords = (monthRecords[String(userId)] ??= {});
142
-
143
- const dayKey = String(day);
144
- if (userRecords[dayKey] != null) {
145
- if (isPast) {
146
- return {
147
- ok: false,
148
- records: loadRecords(scene, userId, year, month),
149
- };
150
- }
151
- userRecords[dayKey] = Number(userRecords[dayKey]) + 1;
173
+ stmts.insertUser.run({ $key: userKey(scene, userId) });
174
+
175
+ const mKey = monthKey(year, month);
176
+ const existing = stmts.getDayRecord.get({
177
+ $scene: scene,
178
+ $monthKey: mKey,
179
+ $userId: userId,
180
+ $day: day,
181
+ }) as { count: number } | null;
182
+
183
+ if (existing && isPast) {
184
+ return {
185
+ ok: false,
186
+ records: loadRecords(scene, userId, year, month),
187
+ };
188
+ }
189
+
190
+ if (existing) {
191
+ stmts.incrementRecord.run({
192
+ $scene: scene,
193
+ $monthKey: mKey,
194
+ $userId: userId,
195
+ $day: day,
196
+ });
152
197
  } else {
153
- userRecords[dayKey] = 1;
198
+ stmts.insertRecord.run({
199
+ $scene: scene,
200
+ $monthKey: mKey,
201
+ $userId: userId,
202
+ $day: day,
203
+ });
154
204
  }
155
205
 
156
- await db.write();
157
206
  return {
158
207
  ok: true,
159
208
  records: loadRecords(scene, userId, year, month),
@@ -161,36 +210,23 @@ export async function initDeerDatabase(): Promise<DeerDatabase> {
161
210
  },
162
211
 
163
212
  getRank(scene, year, month, limit) {
164
- const monthRecords = db.data.records[scene]?.[monthKey(year, month)];
165
- if (!monthRecords) return [];
166
- const totals: DeerRankEntry[] = [];
167
- for (const [uid, days] of Object.entries(monthRecords)) {
168
- let total = 0;
169
- for (const v of Object.values(days)) total += Number(v);
170
- if (total > 0) {
171
- totals.push({ userId: Number(uid), count: total });
172
- }
173
- }
174
- totals.sort((a, b) => b.count - a.count);
175
- return totals.slice(0, limit);
213
+ const rows = stmts.getMonthRank.all({
214
+ $scene: scene,
215
+ $monthKey: monthKey(year, month),
216
+ $limit: limit,
217
+ }) as Array<{ user_id: number; total: number }>;
218
+ return rows.map((row) => ({
219
+ userId: row.user_id,
220
+ count: row.total,
221
+ }));
176
222
  },
177
223
 
178
224
  async cleanupOtherMonths(year, month) {
179
- const keepKey = monthKey(year, month);
180
- let dirty = false;
181
- for (const [scene, sceneRecords] of Object.entries(db.data.records)) {
182
- for (const k of Object.keys(sceneRecords)) {
183
- if (k !== keepKey) {
184
- delete sceneRecords[k];
185
- dirty = true;
186
- }
187
- }
188
- if (Object.keys(sceneRecords).length === 0) {
189
- delete db.data.records[scene];
190
- dirty = true;
191
- }
192
- }
193
- if (dirty) await db.write();
225
+ stmts.deleteOtherMonths.run({ $keepKey: monthKey(year, month) });
226
+ },
227
+
228
+ close() {
229
+ db.close();
194
230
  },
195
231
  };
196
232
  }
package/image.ts CHANGED
@@ -65,17 +65,36 @@ interface AssetUris {
65
65
 
66
66
  let assetsCache: AssetUris | null = null;
67
67
 
68
+ const TRANSPARENT_PNG =
69
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=";
70
+
68
71
  async function loadAssets(): Promise<AssetUris> {
69
72
  if (assetsCache) return assetsCache;
70
73
  const here = path.dirname(fileURLToPath(import.meta.url));
71
74
  const assetsDir = path.join(here, "assets");
75
+
76
+ async function readOptional(name: string): Promise<Buffer | null> {
77
+ try {
78
+ return await fs.readFile(path.join(assetsDir, name));
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
72
84
  const [avatar, check, deerpipe] = await Promise.all([
73
- fs.readFile(path.join(assetsDir, "akkarin@80x80.png")),
74
- fs.readFile(path.join(assetsDir, "check@96x100.png")),
75
- fs.readFile(path.join(assetsDir, "deerpipe@100x82.png")),
85
+ readOptional("akkarin@80x80.png"),
86
+ readOptional("check@96x100.png"),
87
+ readOptional("deerpipe@100x82.png"),
76
88
  ]);
89
+ if (!check || !deerpipe) {
90
+ throw new Error(
91
+ "deerpipe 必需素材缺失:check@96x100.png / deerpipe@100x82.png",
92
+ );
93
+ }
77
94
  assetsCache = {
78
- defaultAvatar: `data:image/png;base64,${avatar.toString("base64")}`,
95
+ defaultAvatar: avatar
96
+ ? `data:image/png;base64,${avatar.toString("base64")}`
97
+ : TRANSPARENT_PNG,
79
98
  check: `data:image/png;base64,${check.toString("base64")}`,
80
99
  deerpipe: `data:image/png;base64,${deerpipe.toString("base64")}`,
81
100
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-deerpipe",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "🦌管签到插件,支持自🦌、帮🦌、补🦌、🦌历、🦌榜",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -16,6 +16,38 @@
16
16
  "screenshot",
17
17
  "help"
18
18
  ],
19
+ "accessHooks": [
20
+ {
21
+ "id": "🦌",
22
+ "match": "/^(?:🦌|鹿)(?:\\s|$|@)/",
23
+ "description": "自🦌或帮🦌"
24
+ },
25
+ {
26
+ "id": "补🦌",
27
+ "match": "/^补(?:🦌|鹿)(?:\\s|\\d|$)/",
28
+ "description": "补签本月之前未签到的某天"
29
+ },
30
+ {
31
+ "id": "🦌历",
32
+ "match": "/^(?:🦌|鹿)历(?:\\s|$|@)/",
33
+ "description": "查看本月🦌签到日历"
34
+ },
35
+ {
36
+ "id": "🦌榜",
37
+ "match": "/^(?:🦌|鹿)榜(?:\\s|$)/",
38
+ "description": "查看本月🦌签到排行榜"
39
+ },
40
+ {
41
+ "id": "帮🦌",
42
+ "match": "/^帮(?:🦌|鹿)(?:\\s|$|@)/",
43
+ "description": "允许/禁止被别人帮🦌"
44
+ },
45
+ {
46
+ "id": "禁🦌",
47
+ "match": "/^禁(?:🦌|鹿)(?:\\s|@|$)/",
48
+ "description": "禁止某人在一段时间内🦌"
49
+ }
50
+ ],
19
51
  "help": {
20
52
  "title": "🦌管签到",
21
53
  "description": "每月🦌管签到,含日历与排行榜",