koishi-plugin-cat-raising 1.2.0 → 1.2.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.
- package/lib/index.js +118 -77
- 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)) {
|
|
@@ -222,10 +229,15 @@ async function fetchBilibiliInfo(ctx, roomId) {
|
|
|
222
229
|
const roomInfo = await ctx.http.get(`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${roomId}`);
|
|
223
230
|
const uid = roomInfo?.data?.uid;
|
|
224
231
|
if (!uid) throw new Error("无法从房间信息中获取UID");
|
|
225
|
-
const statsInfo = await
|
|
232
|
+
const [statsInfo, cardInfo] = await Promise.all([
|
|
233
|
+
ctx.http.get(`https://api.bilibili.com/x/space/navnum?mid=${uid}`),
|
|
234
|
+
ctx.http.get(`https://api.bilibili.com/x/web-interface/card?mid=${uid}`)
|
|
235
|
+
]);
|
|
226
236
|
const videoCount = statsInfo?.data?.video;
|
|
227
237
|
if (videoCount === void 0) throw new Error("无法从空间信息中获取投稿数");
|
|
228
|
-
|
|
238
|
+
const streamerName = cardInfo?.data?.card?.name;
|
|
239
|
+
if (!streamerName) throw new Error("无法从账号信息中获取主播昵称");
|
|
240
|
+
return { videoCount, streamerName };
|
|
229
241
|
} catch (error) {
|
|
230
242
|
ctx.logger.warn(`[API] 获取直播间 ${roomId} 的B站信息失败: ${error.message}`);
|
|
231
243
|
return null;
|
|
@@ -234,25 +246,32 @@ async function fetchBilibiliInfo(ctx, roomId) {
|
|
|
234
246
|
__name(fetchBilibiliInfo, "fetchBilibiliInfo");
|
|
235
247
|
function apply(ctx, config) {
|
|
236
248
|
const forwardedHistory = [];
|
|
249
|
+
const warningMessageMap = /* @__PURE__ */ new Map();
|
|
237
250
|
ctx.on("message", async (session) => {
|
|
238
251
|
const groupConfig = config.monitorGroups.find((g) => g.groupId === session.channelId);
|
|
239
252
|
if (!groupConfig) return;
|
|
240
253
|
const strippedContent = session.stripped.content;
|
|
241
254
|
if (!strippedContent.trim()) return;
|
|
242
255
|
if (HARD_REJECTION_KEYWORDS.some((keyword) => strippedContent.includes(keyword))) return;
|
|
243
|
-
if (CHECK_IN_REJECTION_REGEX.test(strippedContent))
|
|
256
|
+
if (CHECK_IN_REJECTION_REGEX.test(strippedContent)) {
|
|
257
|
+
ctx.logger.info(`[忽略] 消息包含签到模式 (如 110+),判定为非奖励信息。内容: "${strippedContent.replace(/\n/g, " ")}"`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
244
260
|
if (!TRIGGER_REGEX.test(strippedContent)) return;
|
|
245
261
|
const roomIds = extractAllRoomIds(session.content);
|
|
246
262
|
if (roomIds.length !== 1) {
|
|
247
|
-
if (roomIds.length > 1) ctx.logger.info(`[忽略]
|
|
263
|
+
if (roomIds.length > 1) ctx.logger.info(`[忽略] 消息包含多个房间号: ${roomIds.join(", ")}`);
|
|
248
264
|
return;
|
|
249
265
|
}
|
|
250
266
|
const roomId = roomIds[0];
|
|
251
267
|
const preprocessedMessage = preprocessChineseNumerals(strippedContent);
|
|
252
|
-
|
|
268
|
+
const hasRejectionKeyword = REJECTION_KEYWORDS.some((keyword) => preprocessedMessage.includes(keyword));
|
|
269
|
+
if (hasRejectionKeyword && !OVERRIDE_KEYWORDS.some((keyword) => preprocessedMessage.includes(keyword))) return;
|
|
253
270
|
const parsedEvent = parseEventFromText(preprocessedMessage);
|
|
254
271
|
if (!parsedEvent) return;
|
|
255
|
-
|
|
272
|
+
const hasStrongContext = /神金|发|w/i.test(preprocessedMessage);
|
|
273
|
+
const hasTime = parsedEvent.dateTime !== "时间未知";
|
|
274
|
+
if (!hasStrongContext && !hasTime) return;
|
|
256
275
|
const biliInfo = await fetchBilibiliInfo(ctx, roomId);
|
|
257
276
|
if (!biliInfo) return;
|
|
258
277
|
let helperMessageId;
|
|
@@ -273,13 +292,23 @@ function apply(ctx, config) {
|
|
|
273
292
|
const forwardMessage = `${session.content}
|
|
274
293
|
|
|
275
294
|
---
|
|
295
|
+
主播: ${biliInfo.streamerName}
|
|
276
296
|
投稿数: ${biliInfo.videoCount}`;
|
|
277
297
|
const [forwardedMessageId] = config.isGroup ? await session.bot.sendMessage(config.targetQQ, forwardMessage) : await session.bot.sendPrivateMessage(config.targetQQ, forwardMessage);
|
|
278
|
-
forwardedHistory.push({
|
|
298
|
+
forwardedHistory.push({
|
|
299
|
+
originalMessageId: session.messageId,
|
|
300
|
+
forwardedMessageId,
|
|
301
|
+
helperMessageId,
|
|
302
|
+
// 存储辅助消息ID用于撤回联动
|
|
303
|
+
roomId,
|
|
304
|
+
dateTime
|
|
305
|
+
});
|
|
279
306
|
if (forwardedHistory.length > config.historySize) forwardedHistory.shift();
|
|
280
|
-
if (config.biliAccessKeys
|
|
307
|
+
if (config.biliAccessKeys && config.biliAccessKeys.length > 0) {
|
|
281
308
|
ctx.logger.info(`[弹幕] 准备为 ${config.biliAccessKeys.length} 个账号发送弹幕到直播间 ${roomId}...`);
|
|
282
|
-
const danmakuPromises = config.biliAccessKeys.map(
|
|
309
|
+
const danmakuPromises = config.biliAccessKeys.map(
|
|
310
|
+
(keyConfig) => sendBilibiliDanmaku(ctx, keyConfig, roomId, "喵喵喵")
|
|
311
|
+
);
|
|
283
312
|
Promise.allSettled(danmakuPromises);
|
|
284
313
|
}
|
|
285
314
|
} catch (error) {
|
|
@@ -288,7 +317,8 @@ function apply(ctx, config) {
|
|
|
288
317
|
}
|
|
289
318
|
});
|
|
290
319
|
ctx.on("message-deleted", async (session) => {
|
|
291
|
-
|
|
320
|
+
const isMonitored = config.monitorGroups.some((g) => g.groupId === session.channelId);
|
|
321
|
+
if (!isMonitored) return;
|
|
292
322
|
const originalMessageId = session.messageId;
|
|
293
323
|
const entryIndex = forwardedHistory.findIndex((entry) => entry.originalMessageId === originalMessageId);
|
|
294
324
|
if (entryIndex !== -1) {
|
|
@@ -310,6 +340,17 @@ function apply(ctx, config) {
|
|
|
310
340
|
ctx.logger.info(`[撤回] 已联动撤回与源消息 ${originalMessageId} 相关的转发。`);
|
|
311
341
|
}
|
|
312
342
|
}
|
|
343
|
+
if (warningMessageMap.has(originalMessageId)) {
|
|
344
|
+
const warningMessageId = warningMessageMap.get(originalMessageId);
|
|
345
|
+
try {
|
|
346
|
+
await session.bot.deleteMessage(session.channelId, warningMessageId);
|
|
347
|
+
} catch (e) {
|
|
348
|
+
ctx.logger.warn(`[撤回] 警告消息 (ID: ${warningMessageId}) 失败:`, e);
|
|
349
|
+
} finally {
|
|
350
|
+
warningMessageMap.delete(originalMessageId);
|
|
351
|
+
ctx.logger.info(`[撤回] 已联动撤回与源消息 ${originalMessageId} 相关的警告。`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
313
354
|
});
|
|
314
355
|
}
|
|
315
356
|
__name(apply, "apply");
|