koishi-plugin-cat-raising 1.2.0 → 1.3.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/lib/index.js +134 -78
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -46,62 +46,60 @@ var Config = import_koishi.Schema.object({
|
|
|
46
46
|
groupId: import_koishi.Schema.string().description("要监听的 QQ 群号").required(),
|
|
47
47
|
sendHelperMessages: import_koishi.Schema.boolean().description("是否在此群内发送“看到啦”之类的辅助/警告消息").default(true)
|
|
48
48
|
})).description("监听的群组列表及其配置").required(),
|
|
49
|
-
historySize: import_koishi.Schema.number().description("
|
|
49
|
+
historySize: import_koishi.Schema.number().description("用于防复读的历史记录大小,防止短期内对同一活动重复转发").default(30).min(5).max(100),
|
|
50
50
|
biliAccessKeys: import_koishi.Schema.array(import_koishi.Schema.object({
|
|
51
51
|
key: import_koishi.Schema.string().description("Bilibili access_key").required(),
|
|
52
|
-
remark: import_koishi.Schema.string().description("对此 access_key
|
|
53
|
-
})).description("用于发送B站弹幕的 access_key
|
|
52
|
+
remark: import_koishi.Schema.string().description("对此 access_key 的备注,例如所属账号")
|
|
53
|
+
})).description("用于发送B站弹幕的 access_key 列表。插件会为列表中的每个 key 发送弹幕。如果留空,则不执行发送弹幕功能。").default([])
|
|
54
54
|
});
|
|
55
55
|
function preprocessChineseNumerals(text) {
|
|
56
56
|
const numMap = { "零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9 };
|
|
57
|
-
const unitMap = {
|
|
57
|
+
const unitMap = {
|
|
58
|
+
"十": { value: 10, isSection: false },
|
|
59
|
+
"百": { value: 100, isSection: false },
|
|
60
|
+
"千": { value: 1e3, isSection: false },
|
|
61
|
+
"万": { value: 1e4, isSection: true },
|
|
62
|
+
"亿": { value: 1e8, isSection: true }
|
|
63
|
+
};
|
|
58
64
|
const chineseNumRegex = /([一二三四五六七八九十百千万亿两零]+)/g;
|
|
59
65
|
return text.replace(chineseNumRegex, (match) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
if (match.length === 1 && !numMap[match] && !unitMap[match]) return match;
|
|
67
|
+
let total = 0;
|
|
68
|
+
let sectionTotal = 0;
|
|
69
|
+
let currentNum = 0;
|
|
70
|
+
for (const char of match) {
|
|
63
71
|
if (numMap[char] !== void 0) {
|
|
64
|
-
|
|
72
|
+
currentNum = numMap[char];
|
|
65
73
|
} else if (unitMap[char]) {
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
const { value, isSection } = unitMap[char];
|
|
75
|
+
if (value === 10 && currentNum === 0) currentNum = 1;
|
|
76
|
+
sectionTotal += currentNum * value;
|
|
77
|
+
currentNum = 0;
|
|
78
|
+
if (isSection) {
|
|
79
|
+
total += sectionTotal;
|
|
80
|
+
sectionTotal = 0;
|
|
73
81
|
}
|
|
74
|
-
tempVal = 0;
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
|
-
total +=
|
|
84
|
+
total += sectionTotal + currentNum;
|
|
78
85
|
return String(total);
|
|
79
86
|
});
|
|
80
87
|
}
|
|
81
88
|
__name(preprocessChineseNumerals, "preprocessChineseNumerals");
|
|
82
89
|
function extractAllRoomIds(text) {
|
|
83
90
|
const sanitizedText = text.replace(/<[^>]+>/g, "");
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
if (allNumericCandidates.size <= 1) return Array.from(allNumericCandidates);
|
|
96
|
-
const preprocessedText = preprocessChineseNumerals(sanitizedText);
|
|
97
|
-
const rewards = extractRewards(preprocessedText);
|
|
98
|
-
const rewardAmounts = new Set(rewards.map((r) => String(r.amount)));
|
|
99
|
-
let filteredIds = Array.from(allNumericCandidates).filter((id) => !rewardAmounts.has(id));
|
|
100
|
-
if (filteredIds.length > 1) {
|
|
101
|
-
filteredIds.sort((a, b) => b.length - a.length);
|
|
102
|
-
return [filteredIds[0]];
|
|
91
|
+
const patterns = [
|
|
92
|
+
/(?:播间号|房间号|直播间)[::\s]*(\d{3,15})/g,
|
|
93
|
+
/\b(\d{6,15})\b/g
|
|
94
|
+
// 使用单词边界确保匹配的是独立数字
|
|
95
|
+
];
|
|
96
|
+
const foundIds = /* @__PURE__ */ new Set();
|
|
97
|
+
for (const pattern of patterns) {
|
|
98
|
+
for (const match of sanitizedText.matchAll(pattern)) {
|
|
99
|
+
if (match[1]) foundIds.add(match[1]);
|
|
100
|
+
}
|
|
103
101
|
}
|
|
104
|
-
return
|
|
102
|
+
return Array.from(foundIds);
|
|
105
103
|
}
|
|
106
104
|
__name(extractAllRoomIds, "extractAllRoomIds");
|
|
107
105
|
function extractDateTime(line) {
|
|
@@ -124,31 +122,14 @@ function extractDateTime(line) {
|
|
|
124
122
|
__name(extractDateTime, "extractDateTime");
|
|
125
123
|
function extractRewards(line) {
|
|
126
124
|
const rewards = [];
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
rewards.push({ amount, condition });
|
|
131
|
-
foundAmounts.add(amount);
|
|
132
|
-
}
|
|
133
|
-
}, "addReward");
|
|
134
|
-
const strongRegex = /(?:(\d{1,2})\s*级(?:灯牌)?\s*)?(?:(发|掉落)\s*)?(?:(神金|钻石|猫猫钻)\s*(\d+\.?\d*w?|\b\d{3,5}\b)|(\d+\.?\d*w|\b\d{3,5}\b)\s*(?:神金|钻石|猫猫钻|w))/gi;
|
|
135
|
-
for (const match of line.matchAll(strongRegex)) {
|
|
125
|
+
const regex = /(?:(\d{1,2})\s*级(?:灯牌)?\s*)?(?:发\s*)?(\d+\.?\d*w\+?|\b\d{3,5}\b)(?:神金|钻石|猫猫钻)?/gi;
|
|
126
|
+
let match;
|
|
127
|
+
while ((match = regex.exec(line)) !== null) {
|
|
136
128
|
const condition = match[1] ? `${match[1]}级灯牌` : "无限制";
|
|
137
|
-
const amountStr = (match[
|
|
129
|
+
const amountStr = (match[2] || "").toLowerCase();
|
|
138
130
|
const amount = amountStr.includes("w") ? parseFloat(amountStr.replace("w", "")) * 1e4 : parseFloat(amountStr);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (rewards.length === 0) {
|
|
142
|
-
const conditionMatch = line.match(/(\d{1,2})\s*级(?:灯牌)?/);
|
|
143
|
-
if (conditionMatch) {
|
|
144
|
-
const condition = conditionMatch[0];
|
|
145
|
-
const conditionLevel = conditionMatch[1];
|
|
146
|
-
for (const numMatch of line.matchAll(/\b(\d{3,5})\b/g)) {
|
|
147
|
-
if (numMatch[1] !== conditionLevel) {
|
|
148
|
-
const amount = parseFloat(numMatch[1]);
|
|
149
|
-
addReward(amount, condition);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
131
|
+
if (!isNaN(amount) && amount > 0) {
|
|
132
|
+
rewards.push({ amount, condition });
|
|
152
133
|
}
|
|
153
134
|
}
|
|
154
135
|
return rewards;
|
|
@@ -178,25 +159,51 @@ var BILI_APPSECRET = "59b43e04ad6965f34319062b478f83dd";
|
|
|
178
159
|
function signBilibiliParams(params, appSecret) {
|
|
179
160
|
const sortedKeys = Object.keys(params).sort();
|
|
180
161
|
const queryString = sortedKeys.map((key) => `${key}=${params[key]}`).join("&");
|
|
181
|
-
|
|
162
|
+
const sign = crypto.createHash("md5").update(queryString + appSecret).digest("hex");
|
|
163
|
+
return sign;
|
|
182
164
|
}
|
|
183
165
|
__name(signBilibiliParams, "signBilibiliParams");
|
|
184
166
|
async function sendBilibiliDanmaku(ctx, keyConfig, roomId, message) {
|
|
185
|
-
const MAX_RETRIES = 4
|
|
167
|
+
const MAX_RETRIES = 4;
|
|
168
|
+
const RETRY_DELAY_MS = 3e3;
|
|
169
|
+
const FREQUENCY_LIMIT_KEYWORD = "频率过快";
|
|
186
170
|
const url = "https://api.live.bilibili.com/xlive/app-room/v1/dM/sendmsg";
|
|
187
171
|
const logIdentifier = keyConfig.remark || keyConfig.key.slice(0, 8);
|
|
188
172
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
189
|
-
if (attempt > 0)
|
|
173
|
+
if (attempt > 0) {
|
|
174
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
|
175
|
+
}
|
|
190
176
|
const ts = Math.floor(Date.now() / 1e3);
|
|
191
|
-
const baseParams = {
|
|
177
|
+
const baseParams = {
|
|
178
|
+
access_key: keyConfig.key,
|
|
179
|
+
actionKey: "appkey",
|
|
180
|
+
appkey: BILI_APPKEY,
|
|
181
|
+
cid: roomId,
|
|
182
|
+
msg: message,
|
|
183
|
+
rnd: ts,
|
|
184
|
+
color: "16777215",
|
|
185
|
+
// 白色
|
|
186
|
+
fontsize: "25",
|
|
187
|
+
mode: "1",
|
|
188
|
+
// 滚动弹幕
|
|
189
|
+
ts
|
|
190
|
+
};
|
|
192
191
|
const sign = signBilibiliParams(baseParams, BILI_APPSECRET);
|
|
193
192
|
const params = { ...baseParams, sign };
|
|
194
193
|
const formData = new import_url.URLSearchParams();
|
|
195
|
-
for (const key in params)
|
|
194
|
+
for (const key in params) {
|
|
195
|
+
formData.append(key, params[key]);
|
|
196
|
+
}
|
|
196
197
|
try {
|
|
197
|
-
const response = await ctx.http.post(url, formData, {
|
|
198
|
+
const response = await ctx.http.post(url, formData, {
|
|
199
|
+
headers: {
|
|
200
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
201
|
+
"User-Agent": "Mozilla/5.0 BiliDroid/6.73.1 (bbcallen@gmail.com) os/android model/Mi 10 Pro mobi_app/android build/6731100 channel/xiaomi innerVer/6731110 osVer/12 network/2"
|
|
202
|
+
}
|
|
203
|
+
});
|
|
198
204
|
if (response.code === 0) {
|
|
199
|
-
|
|
205
|
+
const successMessage = attempt > 0 ? `[弹幕] [${logIdentifier}] 成功向直播间 ${roomId} 发送弹幕 (重试 ${attempt} 次后)` : `[弹幕] [${logIdentifier}] 成功向直播间 ${roomId} 发送弹幕: "${message}"`;
|
|
206
|
+
ctx.logger.info(successMessage);
|
|
200
207
|
return;
|
|
201
208
|
}
|
|
202
209
|
if (response.message?.includes(FREQUENCY_LIMIT_KEYWORD)) {
|
|
@@ -219,40 +226,68 @@ async function sendBilibiliDanmaku(ctx, keyConfig, roomId, message) {
|
|
|
219
226
|
__name(sendBilibiliDanmaku, "sendBilibiliDanmaku");
|
|
220
227
|
async function fetchBilibiliInfo(ctx, roomId) {
|
|
221
228
|
try {
|
|
222
|
-
const
|
|
229
|
+
const commonHeaders = {
|
|
230
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
231
|
+
"Accept": "application/json, text/plain, */*",
|
|
232
|
+
"Origin": "https://live.bilibili.com",
|
|
233
|
+
"Referer": `https://live.bilibili.com/${roomId}`
|
|
234
|
+
};
|
|
235
|
+
const roomInfo = await ctx.http.get(
|
|
236
|
+
`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${roomId}`,
|
|
237
|
+
{ headers: commonHeaders }
|
|
238
|
+
);
|
|
223
239
|
const uid = roomInfo?.data?.uid;
|
|
224
240
|
if (!uid) throw new Error("无法从房间信息中获取UID");
|
|
225
|
-
const statsInfo = await ctx.http.get(
|
|
241
|
+
const statsInfo = await ctx.http.get(
|
|
242
|
+
`https://api.bilibili.com/x/space/navnum?mid=${uid}`,
|
|
243
|
+
{
|
|
244
|
+
headers: {
|
|
245
|
+
...commonHeaders,
|
|
246
|
+
Origin: "https://space.bilibili.com",
|
|
247
|
+
Referer: `https://space.bilibili.com/${uid}`
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
);
|
|
226
251
|
const videoCount = statsInfo?.data?.video;
|
|
227
252
|
if (videoCount === void 0) throw new Error("无法从空间信息中获取投稿数");
|
|
228
253
|
return { videoCount };
|
|
229
254
|
} catch (error) {
|
|
230
|
-
|
|
255
|
+
const status = error?.response?.status;
|
|
256
|
+
const dataMsg = error?.response?.data?.message;
|
|
257
|
+
const msg = dataMsg || error?.message || "未知错误";
|
|
258
|
+
ctx.logger.warn(`[API] 获取直播间 ${roomId} 的B站信息失败: ${msg}${status ? ` (HTTP ${status})` : ""}`);
|
|
231
259
|
return null;
|
|
232
260
|
}
|
|
233
261
|
}
|
|
234
262
|
__name(fetchBilibiliInfo, "fetchBilibiliInfo");
|
|
235
263
|
function apply(ctx, config) {
|
|
236
264
|
const forwardedHistory = [];
|
|
265
|
+
const warningMessageMap = /* @__PURE__ */ new Map();
|
|
237
266
|
ctx.on("message", async (session) => {
|
|
238
267
|
const groupConfig = config.monitorGroups.find((g) => g.groupId === session.channelId);
|
|
239
268
|
if (!groupConfig) return;
|
|
240
269
|
const strippedContent = session.stripped.content;
|
|
241
270
|
if (!strippedContent.trim()) return;
|
|
242
271
|
if (HARD_REJECTION_KEYWORDS.some((keyword) => strippedContent.includes(keyword))) return;
|
|
243
|
-
if (CHECK_IN_REJECTION_REGEX.test(strippedContent))
|
|
272
|
+
if (CHECK_IN_REJECTION_REGEX.test(strippedContent)) {
|
|
273
|
+
ctx.logger.info(`[忽略] 消息包含签到模式 (如 110+),判定为非奖励信息。内容: "${strippedContent.replace(/\n/g, " ")}"`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
244
276
|
if (!TRIGGER_REGEX.test(strippedContent)) return;
|
|
245
277
|
const roomIds = extractAllRoomIds(session.content);
|
|
246
278
|
if (roomIds.length !== 1) {
|
|
247
|
-
if (roomIds.length > 1) ctx.logger.info(`[忽略]
|
|
279
|
+
if (roomIds.length > 1) ctx.logger.info(`[忽略] 消息包含多个房间号: ${roomIds.join(", ")}`);
|
|
248
280
|
return;
|
|
249
281
|
}
|
|
250
282
|
const roomId = roomIds[0];
|
|
251
283
|
const preprocessedMessage = preprocessChineseNumerals(strippedContent);
|
|
252
|
-
|
|
284
|
+
const hasRejectionKeyword = REJECTION_KEYWORDS.some((keyword) => preprocessedMessage.includes(keyword));
|
|
285
|
+
if (hasRejectionKeyword && !OVERRIDE_KEYWORDS.some((keyword) => preprocessedMessage.includes(keyword))) return;
|
|
253
286
|
const parsedEvent = parseEventFromText(preprocessedMessage);
|
|
254
287
|
if (!parsedEvent) return;
|
|
255
|
-
|
|
288
|
+
const hasStrongContext = /神金|发|w/i.test(preprocessedMessage);
|
|
289
|
+
const hasTime = parsedEvent.dateTime !== "时间未知";
|
|
290
|
+
if (!hasStrongContext && !hasTime) return;
|
|
256
291
|
const biliInfo = await fetchBilibiliInfo(ctx, roomId);
|
|
257
292
|
if (!biliInfo) return;
|
|
258
293
|
let helperMessageId;
|
|
@@ -275,11 +310,20 @@ function apply(ctx, config) {
|
|
|
275
310
|
---
|
|
276
311
|
投稿数: ${biliInfo.videoCount}`;
|
|
277
312
|
const [forwardedMessageId] = config.isGroup ? await session.bot.sendMessage(config.targetQQ, forwardMessage) : await session.bot.sendPrivateMessage(config.targetQQ, forwardMessage);
|
|
278
|
-
forwardedHistory.push({
|
|
313
|
+
forwardedHistory.push({
|
|
314
|
+
originalMessageId: session.messageId,
|
|
315
|
+
forwardedMessageId,
|
|
316
|
+
helperMessageId,
|
|
317
|
+
// 存储辅助消息ID用于撤回联动
|
|
318
|
+
roomId,
|
|
319
|
+
dateTime
|
|
320
|
+
});
|
|
279
321
|
if (forwardedHistory.length > config.historySize) forwardedHistory.shift();
|
|
280
|
-
if (config.biliAccessKeys
|
|
322
|
+
if (config.biliAccessKeys && config.biliAccessKeys.length > 0) {
|
|
281
323
|
ctx.logger.info(`[弹幕] 准备为 ${config.biliAccessKeys.length} 个账号发送弹幕到直播间 ${roomId}...`);
|
|
282
|
-
const danmakuPromises = config.biliAccessKeys.map(
|
|
324
|
+
const danmakuPromises = config.biliAccessKeys.map(
|
|
325
|
+
(keyConfig) => sendBilibiliDanmaku(ctx, keyConfig, roomId, "喵喵喵")
|
|
326
|
+
);
|
|
283
327
|
Promise.allSettled(danmakuPromises);
|
|
284
328
|
}
|
|
285
329
|
} catch (error) {
|
|
@@ -288,7 +332,8 @@ function apply(ctx, config) {
|
|
|
288
332
|
}
|
|
289
333
|
});
|
|
290
334
|
ctx.on("message-deleted", async (session) => {
|
|
291
|
-
|
|
335
|
+
const isMonitored = config.monitorGroups.some((g) => g.groupId === session.channelId);
|
|
336
|
+
if (!isMonitored) return;
|
|
292
337
|
const originalMessageId = session.messageId;
|
|
293
338
|
const entryIndex = forwardedHistory.findIndex((entry) => entry.originalMessageId === originalMessageId);
|
|
294
339
|
if (entryIndex !== -1) {
|
|
@@ -310,6 +355,17 @@ function apply(ctx, config) {
|
|
|
310
355
|
ctx.logger.info(`[撤回] 已联动撤回与源消息 ${originalMessageId} 相关的转发。`);
|
|
311
356
|
}
|
|
312
357
|
}
|
|
358
|
+
if (warningMessageMap.has(originalMessageId)) {
|
|
359
|
+
const warningMessageId = warningMessageMap.get(originalMessageId);
|
|
360
|
+
try {
|
|
361
|
+
await session.bot.deleteMessage(session.channelId, warningMessageId);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
ctx.logger.warn(`[撤回] 警告消息 (ID: ${warningMessageId}) 失败:`, e);
|
|
364
|
+
} finally {
|
|
365
|
+
warningMessageMap.delete(originalMessageId);
|
|
366
|
+
ctx.logger.info(`[撤回] 已联动撤回与源消息 ${originalMessageId} 相关的警告。`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
313
369
|
});
|
|
314
370
|
}
|
|
315
371
|
__name(apply, "apply");
|