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.
Files changed (2) hide show
  1. package/lib/index.js +134 -78
  2. 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("用于防复读的历史记录大小").default(30).min(5).max(100),
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 列表").default([])
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 = { "十": 10, "百": 100, "千": 1e3, "万": 1e4, "亿": 1e8 };
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
- let total = 0, tempVal = 0, sectionVal = 0;
61
- for (let i = 0; i < match.length; i++) {
62
- const char = match[i];
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
- tempVal = numMap[char];
72
+ currentNum = numMap[char];
65
73
  } else if (unitMap[char]) {
66
- const unit = unitMap[char];
67
- if (unit >= 1e4) {
68
- sectionVal += tempVal;
69
- total += sectionVal * unit;
70
- sectionVal = 0;
71
- } else {
72
- sectionVal += (tempVal || 1) * unit;
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 += sectionVal + tempVal;
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 explicitPattern = /(?:播间号|房间号|直播间)[::\s]*(\d{3,15})/gi;
85
- const explicitIds = /* @__PURE__ */ new Set();
86
- for (const match of sanitizedText.matchAll(explicitPattern)) {
87
- if (match[1]) explicitIds.add(match[1]);
88
- }
89
- if (explicitIds.size > 0) return Array.from(explicitIds);
90
- const genericPattern = /\b(\d{3,15})\b/g;
91
- const allNumericCandidates = /* @__PURE__ */ new Set();
92
- for (const match of sanitizedText.matchAll(genericPattern)) {
93
- if (match[1]) allNumericCandidates.add(match[1]);
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 filteredIds;
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 foundAmounts = /* @__PURE__ */ new Set();
128
- const addReward = /* @__PURE__ */ __name((amount, condition) => {
129
- if (!isNaN(amount) && amount > 0 && !foundAmounts.has(amount)) {
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[4] || match[5] || "").toLowerCase();
129
+ const amountStr = (match[2] || "").toLowerCase();
138
130
  const amount = amountStr.includes("w") ? parseFloat(amountStr.replace("w", "")) * 1e4 : parseFloat(amountStr);
139
- addReward(amount, condition);
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
- return crypto.createHash("md5").update(queryString + appSecret).digest("hex");
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, RETRY_DELAY_MS = 3e3, FREQUENCY_LIMIT_KEYWORD = "频率过快";
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) await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
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 = { access_key: keyConfig.key, actionKey: "appkey", appkey: BILI_APPKEY, cid: roomId, msg: message, rnd: ts, color: "16777215", fontsize: "25", mode: "1", ts };
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) formData.append(key, params[key]);
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, { headers: { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": "Mozilla/5.0 BiliDroid/6.73.1" } });
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
- ctx.logger.info(`[弹幕] [${logIdentifier}] 成功向直播间 ${roomId} 发送弹幕${attempt > 0 ? ` (重试 ${attempt} 次后)` : `: "${message}"`}`);
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 roomInfo = await ctx.http.get(`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${roomId}`);
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(`https://api.bilibili.com/x/space/navnum?mid=${uid}`);
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
- ctx.logger.warn(`[API] 获取直播间 ${roomId} 的B站信息失败: ${error.message}`);
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)) return;
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(`[忽略] 消息包含多个可能的房间号: ${roomIds.join(", ")}`);
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
- if (REJECTION_KEYWORDS.some((k) => preprocessedMessage.includes(k)) && !OVERRIDE_KEYWORDS.some((k) => preprocessedMessage.includes(k))) return;
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
- if (!/神金|发|w/i.test(preprocessedMessage) && parsedEvent.dateTime === "时间未知") return;
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({ originalMessageId: session.messageId, forwardedMessageId, helperMessageId, roomId, dateTime });
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?.length > 0) {
322
+ if (config.biliAccessKeys && config.biliAccessKeys.length > 0) {
281
323
  ctx.logger.info(`[弹幕] 准备为 ${config.biliAccessKeys.length} 个账号发送弹幕到直播间 ${roomId}...`);
282
- const danmakuPromises = config.biliAccessKeys.map((keyConfig) => sendBilibiliDanmaku(ctx, keyConfig, roomId, "喵喵喵"));
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
- if (!config.monitorGroups.some((g) => g.groupId === session.channelId)) return;
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");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-cat-raising",
3
3
  "description": "",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [