mioku-plugin-deerpipe 1.0.0
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/LICENSE +661 -0
- package/README.md +33 -0
- package/assets/check@96x100.png +0 -0
- package/assets/deerpipe@100x82.png +0 -0
- package/commands.ts +384 -0
- package/db.ts +196 -0
- package/image.ts +347 -0
- package/index.ts +86 -0
- package/package.json +70 -0
- package/types.ts +23 -0
- package/utils.ts +144 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# mioku-plugin-deerpipe
|
|
2
|
+
|
|
3
|
+
> 本插件移植自 https://github.com/SamuNatsu/nonebot-plugin-deer-pipe ,欢迎原作者接手维护
|
|
4
|
+
> 侵权请发 issue 联系删除,谢谢 :)
|
|
5
|
+
|
|
6
|
+
每月🦌管签到插件。Mioku/TypeScript 版
|
|
7
|
+
|
|
8
|
+
## ✨ 功能
|
|
9
|
+
|
|
10
|
+
- 自🦌:每天签到
|
|
11
|
+
- 帮🦌:帮别人签到
|
|
12
|
+
- 补🦌:补本月之前漏签的日子
|
|
13
|
+
- 🦌历:查看自己/别人的本月🦌签到日历
|
|
14
|
+
- 🦌榜:查看本群本月🦌签到排行榜
|
|
15
|
+
- 帮🦌 on/off:允许/禁止被别人帮🦌
|
|
16
|
+
- 禁🦌:管理员禁止某人在一段时间内🦌
|
|
17
|
+
|
|
18
|
+
所有命令中的「🦌」都可以用「鹿」字代替。
|
|
19
|
+
|
|
20
|
+
## 🎉 指令
|
|
21
|
+
|
|
22
|
+
| 指令 | 说明 | 权限 |
|
|
23
|
+
|-------------------|---------------------|------|
|
|
24
|
+
| `🦌` / `鹿` | 自🦌一次 | 全员 |
|
|
25
|
+
| `🦌 @某人` | 帮某人🦌一次(仅群组) | 全员 |
|
|
26
|
+
| `补🦌 <日>` | 补签本月某天 | 全员 |
|
|
27
|
+
| `🦌历` / `🦌历 @某人` | 查看本月🦌日历 | 全员 |
|
|
28
|
+
| `🦌榜` | 查看本月本群🦌排行榜(仅群组) | 全员 |
|
|
29
|
+
| `帮🦌 on/off` | 允许/禁止别人帮自己🦌 | 全员 |
|
|
30
|
+
| `帮🦌 on/off @某人` | 允许/禁止帮某人🦌 | 群管理员 |
|
|
31
|
+
| `禁🦌 @某人 [时长]` | 禁止某人🦌一段时间,省略时长视为解禁 | 群管理员 |
|
|
32
|
+
|
|
33
|
+
时长支持 `30s` / `5m` / `2h` / `1d` / `1w` 等写法,最长 30 天。
|
|
Binary file
|
|
Binary file
|
package/commands.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import type { ScreenshotService } from "mioku";
|
|
2
|
+
import type { MiokiContext } from "mioki";
|
|
3
|
+
import type { DeerDatabase } from "./db";
|
|
4
|
+
import { generateCalendarImage, generateRankImage } from "./image";
|
|
5
|
+
import {
|
|
6
|
+
formatDateTime,
|
|
7
|
+
getAtUserId,
|
|
8
|
+
parseDuration,
|
|
9
|
+
replyImage,
|
|
10
|
+
resolveScene,
|
|
11
|
+
resolveUserName,
|
|
12
|
+
} from "./utils";
|
|
13
|
+
|
|
14
|
+
const MAX_NO_DEER_DURATION_S = 30 * 86400;
|
|
15
|
+
|
|
16
|
+
interface CommandContext {
|
|
17
|
+
ctx: MiokiContext;
|
|
18
|
+
db: DeerDatabase;
|
|
19
|
+
screenshot: ScreenshotService;
|
|
20
|
+
event: any;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalize(text: string): string {
|
|
24
|
+
return text.replace(/鹿/g, "🦌").trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isGroupAdmin(event: any): boolean {
|
|
28
|
+
const role = event?.sender?.role;
|
|
29
|
+
return role === "admin" || role === "owner";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type DeerCommand =
|
|
33
|
+
| { type: "deer"; targetUserId?: number }
|
|
34
|
+
| { type: "past"; day: number }
|
|
35
|
+
| { type: "calendar"; targetUserId?: number }
|
|
36
|
+
| { type: "rank" }
|
|
37
|
+
| { type: "set_can_be_helped"; allowed: boolean; targetUserId?: number }
|
|
38
|
+
| { type: "set_no_deer"; targetUserId: number; durationText?: string }
|
|
39
|
+
| { type: "invalid_past" }
|
|
40
|
+
| { type: "none" };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse a message into a deer command.
|
|
44
|
+
* Returns "none" if the message doesn't look like a deer command at all.
|
|
45
|
+
*/
|
|
46
|
+
export function parseDeerCommand(
|
|
47
|
+
text: string,
|
|
48
|
+
message: any[],
|
|
49
|
+
): DeerCommand {
|
|
50
|
+
const normalized = normalize(text);
|
|
51
|
+
if (!normalized) return { type: "none" };
|
|
52
|
+
const tokens = normalized.split(/\s+/).filter(Boolean);
|
|
53
|
+
const head = tokens[0];
|
|
54
|
+
if (!head) return { type: "none" };
|
|
55
|
+
|
|
56
|
+
if (head === "🦌") {
|
|
57
|
+
return { type: "deer", targetUserId: getAtUserId(message) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (head === "补🦌") {
|
|
61
|
+
const arg = tokens[1];
|
|
62
|
+
if (!arg) return { type: "invalid_past" };
|
|
63
|
+
const day = Number(arg);
|
|
64
|
+
if (!Number.isInteger(day)) return { type: "invalid_past" };
|
|
65
|
+
return { type: "past", day };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (head === "🦌历") {
|
|
69
|
+
return { type: "calendar", targetUserId: getAtUserId(message) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (head === "🦌榜") {
|
|
73
|
+
return { type: "rank" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (head === "帮🦌") {
|
|
77
|
+
const flag = (tokens[1] || "").toLowerCase();
|
|
78
|
+
if (flag !== "on" && flag !== "off") return { type: "none" };
|
|
79
|
+
return {
|
|
80
|
+
type: "set_can_be_helped",
|
|
81
|
+
allowed: flag === "on",
|
|
82
|
+
targetUserId: getAtUserId(message),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (head === "禁🦌") {
|
|
87
|
+
const targetUserId = getAtUserId(message);
|
|
88
|
+
if (!targetUserId) return { type: "none" };
|
|
89
|
+
const durationText = tokens.slice(1).join("").trim();
|
|
90
|
+
return {
|
|
91
|
+
type: "set_no_deer",
|
|
92
|
+
targetUserId,
|
|
93
|
+
durationText: durationText || undefined,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { type: "none" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function handleDeerCommand(
|
|
101
|
+
cmd: DeerCommand,
|
|
102
|
+
cmdCtx: CommandContext,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
switch (cmd.type) {
|
|
105
|
+
case "deer":
|
|
106
|
+
return handleDeer(cmdCtx, cmd.targetUserId);
|
|
107
|
+
case "past":
|
|
108
|
+
return handlePast(cmdCtx, cmd.day);
|
|
109
|
+
case "calendar":
|
|
110
|
+
return handleCalendar(cmdCtx, cmd.targetUserId);
|
|
111
|
+
case "rank":
|
|
112
|
+
return handleRank(cmdCtx);
|
|
113
|
+
case "set_can_be_helped":
|
|
114
|
+
return handleSetCanBeHelped(cmdCtx, cmd.allowed, cmd.targetUserId);
|
|
115
|
+
case "set_no_deer":
|
|
116
|
+
return handleSetNoDeer(cmdCtx, cmd.targetUserId, cmd.durationText);
|
|
117
|
+
case "invalid_past":
|
|
118
|
+
await cmdCtx.event.reply("不是合法的补🦌日期捏", true);
|
|
119
|
+
return;
|
|
120
|
+
case "none":
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function handleDeer(
|
|
126
|
+
cmdCtx: CommandContext,
|
|
127
|
+
targetUserId?: number,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
const { ctx, db, screenshot, event } = cmdCtx;
|
|
130
|
+
const scene = resolveScene(event);
|
|
131
|
+
const now = new Date();
|
|
132
|
+
|
|
133
|
+
// 帮🦌 only makes sense in groups
|
|
134
|
+
if (targetUserId && !scene.isGroup) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const userId =
|
|
139
|
+
targetUserId != null ? targetUserId : Number(event.user_id);
|
|
140
|
+
const user = db.getOrCreateUser(scene.key, userId);
|
|
141
|
+
|
|
142
|
+
if (targetUserId && !user.canBeHelped) {
|
|
143
|
+
await event.reply("该用户不准别人帮🦌捏", true);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
scene.isGroup &&
|
|
149
|
+
user.noDeerUntil != null &&
|
|
150
|
+
user.noDeerUntil > now.getTime()
|
|
151
|
+
) {
|
|
152
|
+
await event.reply(
|
|
153
|
+
`该用户已被禁🦌至 ${formatDateTime(user.noDeerUntil)}`,
|
|
154
|
+
true,
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = await db.checkIn(
|
|
160
|
+
scene.key,
|
|
161
|
+
userId,
|
|
162
|
+
now.getFullYear(),
|
|
163
|
+
now.getMonth() + 1,
|
|
164
|
+
now.getDate(),
|
|
165
|
+
false,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const name = await resolveUserName(ctx, event, userId);
|
|
169
|
+
const imagePath = await generateCalendarImage(screenshot, {
|
|
170
|
+
year: now.getFullYear(),
|
|
171
|
+
month: now.getMonth() + 1,
|
|
172
|
+
todayDay: now.getDate(),
|
|
173
|
+
records: result.records,
|
|
174
|
+
name,
|
|
175
|
+
userId,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const prefix = targetUserId
|
|
179
|
+
? [{ type: "text", data: { text: "成功帮 " } }, ctx.segment.at(targetUserId), { type: "text", data: { text: " 🦌了\n" } }]
|
|
180
|
+
: [{ type: "text", data: { text: "成功🦌了\n" } }];
|
|
181
|
+
|
|
182
|
+
await replyImage(event, ctx.segment, imagePath, prefix);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function handlePast(
|
|
186
|
+
cmdCtx: CommandContext,
|
|
187
|
+
day: number,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
const { ctx, db, screenshot, event } = cmdCtx;
|
|
190
|
+
const scene = resolveScene(event);
|
|
191
|
+
const now = new Date();
|
|
192
|
+
|
|
193
|
+
if (day < 1 || day >= now.getDate()) {
|
|
194
|
+
await event.reply("不是合法的补🦌日期捏", true);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const userId = Number(event.user_id);
|
|
199
|
+
db.getOrCreateUser(scene.key, userId);
|
|
200
|
+
|
|
201
|
+
const result = await db.checkIn(
|
|
202
|
+
scene.key,
|
|
203
|
+
userId,
|
|
204
|
+
now.getFullYear(),
|
|
205
|
+
now.getMonth() + 1,
|
|
206
|
+
day,
|
|
207
|
+
true,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const name = await resolveUserName(ctx, event, userId);
|
|
211
|
+
const imagePath = await generateCalendarImage(screenshot, {
|
|
212
|
+
year: now.getFullYear(),
|
|
213
|
+
month: now.getMonth() + 1,
|
|
214
|
+
todayDay: now.getDate(),
|
|
215
|
+
records: result.records,
|
|
216
|
+
name,
|
|
217
|
+
userId,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const text = result.ok ? "成功补🦌\n" : "不能补🦌已经🦌过的日子捏\n";
|
|
221
|
+
await replyImage(
|
|
222
|
+
event,
|
|
223
|
+
ctx.segment,
|
|
224
|
+
imagePath,
|
|
225
|
+
[{ type: "text", data: { text } }],
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function handleCalendar(
|
|
230
|
+
cmdCtx: CommandContext,
|
|
231
|
+
targetUserId?: number,
|
|
232
|
+
): Promise<void> {
|
|
233
|
+
const { ctx, db, screenshot, event } = cmdCtx;
|
|
234
|
+
const scene = resolveScene(event);
|
|
235
|
+
if (targetUserId && !scene.isGroup) return;
|
|
236
|
+
|
|
237
|
+
const now = new Date();
|
|
238
|
+
const userId = targetUserId ?? Number(event.user_id);
|
|
239
|
+
db.getOrCreateUser(scene.key, userId);
|
|
240
|
+
const records = db.getRecords(
|
|
241
|
+
scene.key,
|
|
242
|
+
userId,
|
|
243
|
+
now.getFullYear(),
|
|
244
|
+
now.getMonth() + 1,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const name = await resolveUserName(ctx, event, userId);
|
|
248
|
+
const imagePath = await generateCalendarImage(screenshot, {
|
|
249
|
+
year: now.getFullYear(),
|
|
250
|
+
month: now.getMonth() + 1,
|
|
251
|
+
todayDay: now.getDate(),
|
|
252
|
+
records,
|
|
253
|
+
name,
|
|
254
|
+
userId,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await replyImage(event, ctx.segment, imagePath);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function handleRank(cmdCtx: CommandContext): Promise<void> {
|
|
261
|
+
const { ctx, db, screenshot, event } = cmdCtx;
|
|
262
|
+
const scene = resolveScene(event);
|
|
263
|
+
if (!scene.isGroup) return;
|
|
264
|
+
|
|
265
|
+
const now = new Date();
|
|
266
|
+
const top = db.getRank(
|
|
267
|
+
scene.key,
|
|
268
|
+
now.getFullYear(),
|
|
269
|
+
now.getMonth() + 1,
|
|
270
|
+
5,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const rows = await Promise.all(
|
|
274
|
+
top.map(async (entry, idx) => ({
|
|
275
|
+
rank: idx + 1,
|
|
276
|
+
userId: entry.userId,
|
|
277
|
+
name: await resolveUserName(ctx, event, entry.userId),
|
|
278
|
+
count: entry.count,
|
|
279
|
+
})),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const imagePath = await generateRankImage(
|
|
283
|
+
screenshot,
|
|
284
|
+
rows,
|
|
285
|
+
now.getFullYear(),
|
|
286
|
+
now.getMonth() + 1,
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
await replyImage(event, ctx.segment, imagePath);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function handleSetCanBeHelped(
|
|
293
|
+
cmdCtx: CommandContext,
|
|
294
|
+
allowed: boolean,
|
|
295
|
+
targetUserId?: number,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
const { ctx, db, event } = cmdCtx;
|
|
298
|
+
const scene = resolveScene(event);
|
|
299
|
+
if (!scene.isGroup) return;
|
|
300
|
+
|
|
301
|
+
if (targetUserId && !isGroupAdmin(event)) {
|
|
302
|
+
await event.reply("权限不足", true);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const userId = targetUserId ?? Number(event.user_id);
|
|
307
|
+
const user = db.getOrCreateUser(scene.key, userId);
|
|
308
|
+
user.canBeHelped = allowed;
|
|
309
|
+
await db.updateUser(user);
|
|
310
|
+
|
|
311
|
+
if (targetUserId) {
|
|
312
|
+
await event.reply(
|
|
313
|
+
[
|
|
314
|
+
{ type: "text", data: { text: `已${allowed ? "允许" : "禁止"}帮 ` } },
|
|
315
|
+
ctx.segment.at(targetUserId),
|
|
316
|
+
{ type: "text", data: { text: " 🦌" } },
|
|
317
|
+
],
|
|
318
|
+
true,
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
await event.reply(`已${allowed ? "允许" : "禁止"}别人帮🦌`, true);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function handleSetNoDeer(
|
|
326
|
+
cmdCtx: CommandContext,
|
|
327
|
+
targetUserId: number,
|
|
328
|
+
durationText?: string,
|
|
329
|
+
): Promise<void> {
|
|
330
|
+
const { ctx, db, event } = cmdCtx;
|
|
331
|
+
const scene = resolveScene(event);
|
|
332
|
+
if (!scene.isGroup) return;
|
|
333
|
+
|
|
334
|
+
if (!isGroupAdmin(event)) {
|
|
335
|
+
await event.reply("权限不足", true);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let durationS: number | null = null;
|
|
340
|
+
if (durationText) {
|
|
341
|
+
durationS = parseDuration(durationText);
|
|
342
|
+
if (durationS == null) {
|
|
343
|
+
await event.reply("时间段表达式解析错误", true);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (durationS > MAX_NO_DEER_DURATION_S) {
|
|
347
|
+
await event.reply(
|
|
348
|
+
`时间段过长:最大允许时间为 ${MAX_NO_DEER_DURATION_S / 86400} 天`,
|
|
349
|
+
true,
|
|
350
|
+
);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const user = db.getOrCreateUser(scene.key, targetUserId);
|
|
356
|
+
user.noDeerUntil =
|
|
357
|
+
durationS == null ? null : Date.now() + durationS * 1000;
|
|
358
|
+
await db.updateUser(user);
|
|
359
|
+
|
|
360
|
+
if (user.noDeerUntil == null) {
|
|
361
|
+
await event.reply(
|
|
362
|
+
[
|
|
363
|
+
{ type: "text", data: { text: "已解禁 " } },
|
|
364
|
+
ctx.segment.at(targetUserId),
|
|
365
|
+
{ type: "text", data: { text: " 的🦌权" } },
|
|
366
|
+
],
|
|
367
|
+
true,
|
|
368
|
+
);
|
|
369
|
+
} else {
|
|
370
|
+
await event.reply(
|
|
371
|
+
[
|
|
372
|
+
{ type: "text", data: { text: "已禁止 " } },
|
|
373
|
+
ctx.segment.at(targetUserId),
|
|
374
|
+
{
|
|
375
|
+
type: "text",
|
|
376
|
+
data: {
|
|
377
|
+
text: ` 的🦌权至 ${formatDateTime(user.noDeerUntil)}`,
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
true,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
package/db.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createDB } from "mioki";
|
|
2
|
+
import { ensureDataDir } from "mioku";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import type {
|
|
5
|
+
DeerCheckInResult,
|
|
6
|
+
DeerRankEntry,
|
|
7
|
+
DeerUser,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
interface UserRecord {
|
|
11
|
+
canBeHelped: boolean;
|
|
12
|
+
noDeerUntil: number | null;
|
|
13
|
+
}
|
|
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
|
+
>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_STORE: DeerStore = {
|
|
28
|
+
users: {},
|
|
29
|
+
records: {},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface DeerDatabase {
|
|
33
|
+
getOrCreateUser(scene: string, userId: number): DeerUser;
|
|
34
|
+
updateUser(user: DeerUser): Promise<void>;
|
|
35
|
+
getRecords(
|
|
36
|
+
scene: string,
|
|
37
|
+
userId: number,
|
|
38
|
+
year: number,
|
|
39
|
+
month: number,
|
|
40
|
+
): Map<number, number>;
|
|
41
|
+
checkIn(
|
|
42
|
+
scene: string,
|
|
43
|
+
userId: number,
|
|
44
|
+
year: number,
|
|
45
|
+
month: number,
|
|
46
|
+
day: number,
|
|
47
|
+
isPast: boolean,
|
|
48
|
+
): Promise<DeerCheckInResult>;
|
|
49
|
+
getRank(
|
|
50
|
+
scene: string,
|
|
51
|
+
year: number,
|
|
52
|
+
month: number,
|
|
53
|
+
limit: number,
|
|
54
|
+
): DeerRankEntry[];
|
|
55
|
+
cleanupOtherMonths(year: number, month: number): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function userKey(scene: string, userId: number): string {
|
|
59
|
+
return `${scene}:${userId}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function monthKey(year: number, month: number): string {
|
|
63
|
+
return `${year}-${String(month).padStart(2, "0")}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function initDeerDatabase(): Promise<DeerDatabase> {
|
|
67
|
+
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
|
+
}
|
|
100
|
+
|
|
101
|
+
function loadRecords(
|
|
102
|
+
scene: string,
|
|
103
|
+
userId: number,
|
|
104
|
+
year: number,
|
|
105
|
+
month: number,
|
|
106
|
+
): Map<number, number> {
|
|
107
|
+
const raw = readMonthRecords(scene, userId, year, month);
|
|
108
|
+
const map = new Map<number, number>();
|
|
109
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
110
|
+
map.set(Number(k), Number(v));
|
|
111
|
+
}
|
|
112
|
+
return map;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
getOrCreateUser(scene, userId) {
|
|
117
|
+
const row = readUserRecord(scene, userId);
|
|
118
|
+
return {
|
|
119
|
+
scene,
|
|
120
|
+
userId,
|
|
121
|
+
canBeHelped: row.canBeHelped,
|
|
122
|
+
noDeerUntil: row.noDeerUntil,
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
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();
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
getRecords(scene, userId, year, month) {
|
|
134
|
+
return loadRecords(scene, userId, year, month);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
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;
|
|
152
|
+
} else {
|
|
153
|
+
userRecords[dayKey] = 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await db.write();
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
records: loadRecords(scene, userId, year, month),
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
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);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
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();
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|