koishi-plugin-cat-raising 1.0.1 → 1.2.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.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { Context, Schema } from 'koishi';
2
2
  export declare const name = "cat-raising";
3
+ /** B站 access_key 配置项 */
4
+ export interface BiliAccessKeyConfig {
5
+ /** Bilibili access_key */
6
+ key: string;
7
+ /** 对此 access_key 的备注,例如所属账号 */
8
+ remark?: string;
9
+ }
3
10
  /** 监听群组的配置 */
4
11
  export interface MonitorGroupConfig {
5
12
  /** 要监听的 QQ 群号 */
@@ -18,7 +25,7 @@ export interface Config {
18
25
  /** 用于防复读的历史记录大小 */
19
26
  historySize: number;
20
27
  /** 用于发送B站弹幕的 access_key 列表 */
21
- biliAccessKeys: string[];
28
+ biliAccessKeys: BiliAccessKeyConfig[];
22
29
  }
23
30
  export declare const Config: Schema<Config>;
24
31
  /**
package/lib/index.js CHANGED
@@ -46,57 +46,62 @@ 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),
50
- biliAccessKeys: import_koishi.Schema.array(import_koishi.Schema.string()).description("用于发送B站弹幕的 access_key 列表。插件会随机选择一个使用。如果留空,则不执行发送弹幕功能。").default([])
49
+ historySize: import_koishi.Schema.number().description("用于防复读的历史记录大小").default(30).min(5).max(100),
50
+ biliAccessKeys: import_koishi.Schema.array(import_koishi.Schema.object({
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([])
51
54
  });
52
55
  function preprocessChineseNumerals(text) {
53
56
  const numMap = { "零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9 };
54
- const unitMap = {
55
- "十": { value: 10, isSection: false },
56
- "百": { value: 100, isSection: false },
57
- "千": { value: 1e3, isSection: false },
58
- "万": { value: 1e4, isSection: true },
59
- "亿": { value: 1e8, isSection: true }
60
- };
57
+ const unitMap = { "十": 10, "百": 100, "千": 1e3, "万": 1e4, "亿": 1e8 };
61
58
  const chineseNumRegex = /([一二三四五六七八九十百千万亿两零]+)/g;
62
59
  return text.replace(chineseNumRegex, (match) => {
63
- if (match.length === 1 && !numMap[match] && !unitMap[match]) return match;
64
- let total = 0;
65
- let sectionTotal = 0;
66
- let currentNum = 0;
67
- for (const char of match) {
60
+ let total = 0, tempVal = 0, sectionVal = 0;
61
+ for (let i = 0; i < match.length; i++) {
62
+ const char = match[i];
68
63
  if (numMap[char] !== void 0) {
69
- currentNum = numMap[char];
64
+ tempVal = numMap[char];
70
65
  } else if (unitMap[char]) {
71
- const { value, isSection } = unitMap[char];
72
- if (value === 10 && currentNum === 0) currentNum = 1;
73
- sectionTotal += currentNum * value;
74
- currentNum = 0;
75
- if (isSection) {
76
- total += sectionTotal;
77
- sectionTotal = 0;
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;
78
73
  }
74
+ tempVal = 0;
79
75
  }
80
76
  }
81
- total += sectionTotal + currentNum;
77
+ total += sectionVal + tempVal;
82
78
  return String(total);
83
79
  });
84
80
  }
85
81
  __name(preprocessChineseNumerals, "preprocessChineseNumerals");
86
82
  function extractAllRoomIds(text) {
87
83
  const sanitizedText = text.replace(/<[^>]+>/g, "");
88
- const patterns = [
89
- /(?:播间号|房间号|直播间)[::\s]*(\d{3,15})/g,
90
- /\b(\d{6,15})\b/g
91
- // 使用单词边界确保匹配的是独立数字
92
- ];
93
- const foundIds = /* @__PURE__ */ new Set();
94
- for (const pattern of patterns) {
95
- for (const match of sanitizedText.matchAll(pattern)) {
96
- if (match[1]) foundIds.add(match[1]);
97
- }
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]];
98
103
  }
99
- return Array.from(foundIds);
104
+ return filteredIds;
100
105
  }
101
106
  __name(extractAllRoomIds, "extractAllRoomIds");
102
107
  function extractDateTime(line) {
@@ -119,14 +124,31 @@ function extractDateTime(line) {
119
124
  __name(extractDateTime, "extractDateTime");
120
125
  function extractRewards(line) {
121
126
  const rewards = [];
122
- const regex = /(?:(\d{1,2})\s*级(?:灯牌)?\s*)?(?:发\s*)?(\d+\.?\d*w\+?|\b\d{3,5}\b)(?:神金|钻石|猫猫钻)?/gi;
123
- let match;
124
- while ((match = regex.exec(line)) !== null) {
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
136
  const condition = match[1] ? `${match[1]}级灯牌` : "无限制";
126
- const amountStr = (match[2] || "").toLowerCase();
137
+ const amountStr = (match[4] || match[5] || "").toLowerCase();
127
138
  const amount = amountStr.includes("w") ? parseFloat(amountStr.replace("w", "")) * 1e4 : parseFloat(amountStr);
128
- if (!isNaN(amount) && amount > 0) {
129
- rewards.push({ amount, condition });
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
+ }
130
152
  }
131
153
  }
132
154
  return rewards;
@@ -149,58 +171,49 @@ __name(parseEventFromText, "parseEventFromText");
149
171
  var HARD_REJECTION_KEYWORDS = ["发言榜单", "投稿数:"];
150
172
  var REJECTION_KEYWORDS = ["签到", "打卡"];
151
173
  var OVERRIDE_KEYWORDS = ["神金", "发"];
174
+ var CHECK_IN_REJECTION_REGEX = /\b\d{2,3}\s*\+/;
152
175
  var TRIGGER_REGEX = /神金|发|掉落|猫猫钻|w|\b\d{3,5}\b|一千|一百|十|九|八|七|六|五|四|三|两|二|一/i;
153
176
  var BILI_APPKEY = "4409e2ce8ffd12b8";
154
177
  var BILI_APPSECRET = "59b43e04ad6965f34319062b478f83dd";
155
178
  function signBilibiliParams(params, appSecret) {
156
179
  const sortedKeys = Object.keys(params).sort();
157
180
  const queryString = sortedKeys.map((key) => `${key}=${params[key]}`).join("&");
158
- const sign = crypto.createHash("md5").update(queryString + appSecret).digest("hex");
159
- return sign;
181
+ return crypto.createHash("md5").update(queryString + appSecret).digest("hex");
160
182
  }
161
183
  __name(signBilibiliParams, "signBilibiliParams");
162
- async function sendBilibiliDanmaku(ctx, config, roomId, message) {
163
- if (!config.biliAccessKeys || config.biliAccessKeys.length === 0) {
164
- ctx.logger.info("[弹幕] 未配置 access_key,跳过发送弹幕。");
165
- return;
166
- }
167
- const accessKey = config.biliAccessKeys[Math.floor(Math.random() * config.biliAccessKeys.length)];
184
+ async function sendBilibiliDanmaku(ctx, keyConfig, roomId, message) {
185
+ const MAX_RETRIES = 4, RETRY_DELAY_MS = 3e3, FREQUENCY_LIMIT_KEYWORD = "频率过快";
168
186
  const url = "https://api.live.bilibili.com/xlive/app-room/v1/dM/sendmsg";
169
- const ts = Math.floor(Date.now() / 1e3);
170
- const baseParams = {
171
- access_key: accessKey,
172
- actionKey: "appkey",
173
- appkey: BILI_APPKEY,
174
- cid: roomId,
175
- msg: message,
176
- rnd: ts,
177
- color: "16777215",
178
- // 白色
179
- fontsize: "25",
180
- mode: "1",
181
- // 滚动弹幕
182
- ts
183
- };
184
- const sign = signBilibiliParams(baseParams, BILI_APPSECRET);
185
- const params = { ...baseParams, sign };
186
- const formData = new import_url.URLSearchParams();
187
- for (const key in params) {
188
- formData.append(key, params[key]);
189
- }
190
- try {
191
- const response = await ctx.http.post(url, formData, {
192
- headers: {
193
- "Content-Type": "application/x-www-form-urlencoded",
194
- "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"
187
+ const logIdentifier = keyConfig.remark || keyConfig.key.slice(0, 8);
188
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
189
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
190
+ 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 };
192
+ const sign = signBilibiliParams(baseParams, BILI_APPSECRET);
193
+ const params = { ...baseParams, sign };
194
+ const formData = new import_url.URLSearchParams();
195
+ for (const key in params) formData.append(key, params[key]);
196
+ 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
+ if (response.code === 0) {
199
+ ctx.logger.info(`[弹幕] [${logIdentifier}] 成功向直播间 ${roomId} 发送弹幕${attempt > 0 ? ` (重试 ${attempt} 次后)` : `: "${message}"`}`);
200
+ return;
201
+ }
202
+ if (response.message?.includes(FREQUENCY_LIMIT_KEYWORD)) {
203
+ if (attempt < MAX_RETRIES) {
204
+ ctx.logger.warn(`[弹幕] [${logIdentifier}] 发送频率过快 (尝试 ${attempt + 1}/${MAX_RETRIES + 1})。准备重试...`);
205
+ continue;
206
+ } else {
207
+ ctx.logger.warn(`[弹幕] [${logIdentifier}] 发送频率过快,已达最大重试次数 (${MAX_RETRIES}),放弃发送。`);
208
+ return;
209
+ }
195
210
  }
196
- });
197
- if (response.code === 0) {
198
- ctx.logger.info(`[弹幕] 成功向直播间 ${roomId} 发送弹幕: "${message}"`);
199
- } else {
200
- ctx.logger.warn(`[弹幕] 发送失败,直播间 ${roomId}。原因: ${response.message || "未知错误"}`);
211
+ ctx.logger.warn(`[弹幕] [${logIdentifier}] 发送失败,直播间 ${roomId}。原因: ${response.message || "未知错误"}`);
212
+ return;
213
+ } catch (error) {
214
+ ctx.logger.error(`[弹幕] [${logIdentifier}] 发送请求时发生网络错误 (尝试 ${attempt + 1}),直播间 ${roomId}:`, error);
215
+ return;
201
216
  }
202
- } catch (error) {
203
- ctx.logger.error(`[弹幕] 发送请求时发生网络错误,直播间 ${roomId}:`, error);
204
217
  }
205
218
  }
206
219
  __name(sendBilibiliDanmaku, "sendBilibiliDanmaku");
@@ -221,47 +234,40 @@ async function fetchBilibiliInfo(ctx, roomId) {
221
234
  __name(fetchBilibiliInfo, "fetchBilibiliInfo");
222
235
  function apply(ctx, config) {
223
236
  const forwardedHistory = [];
224
- const warningMessageMap = /* @__PURE__ */ new Map();
225
237
  ctx.on("message", async (session) => {
226
238
  const groupConfig = config.monitorGroups.find((g) => g.groupId === session.channelId);
227
239
  if (!groupConfig) return;
228
240
  const strippedContent = session.stripped.content;
229
241
  if (!strippedContent.trim()) return;
230
242
  if (HARD_REJECTION_KEYWORDS.some((keyword) => strippedContent.includes(keyword))) return;
243
+ if (CHECK_IN_REJECTION_REGEX.test(strippedContent)) return;
231
244
  if (!TRIGGER_REGEX.test(strippedContent)) return;
232
245
  const roomIds = extractAllRoomIds(session.content);
233
246
  if (roomIds.length !== 1) {
234
- if (roomIds.length > 1) ctx.logger.info(`[忽略] 消息包含多个房间号: ${roomIds.join(", ")}`);
247
+ if (roomIds.length > 1) ctx.logger.info(`[忽略] 消息包含多个可能的房间号: ${roomIds.join(", ")}`);
235
248
  return;
236
249
  }
237
250
  const roomId = roomIds[0];
238
251
  const preprocessedMessage = preprocessChineseNumerals(strippedContent);
239
- const hasRejectionKeyword = REJECTION_KEYWORDS.some((keyword) => preprocessedMessage.includes(keyword));
240
- if (hasRejectionKeyword && !OVERRIDE_KEYWORDS.some((keyword) => preprocessedMessage.includes(keyword))) return;
252
+ if (REJECTION_KEYWORDS.some((k) => preprocessedMessage.includes(k)) && !OVERRIDE_KEYWORDS.some((k) => preprocessedMessage.includes(k))) return;
241
253
  const parsedEvent = parseEventFromText(preprocessedMessage);
242
254
  if (!parsedEvent) return;
243
- const hasStrongContext = /神金|发|w/i.test(preprocessedMessage);
244
- const hasTime = parsedEvent.dateTime !== "时间未知";
245
- if (!hasStrongContext && !hasTime) return;
246
- const { dateTime } = parsedEvent;
247
- if (forwardedHistory.some((entry) => entry.roomId === roomId && entry.dateTime === dateTime)) {
248
- ctx.logger.info(`[防复读] 检测到重复活动: 房间=${roomId}, 时间=${dateTime}`);
249
- if (groupConfig.sendHelperMessages) {
250
- try {
251
- const [warningId] = await session.send(`看到啦看到啦,不要发那么多次嘛~`);
252
- if (warningId) warningMessageMap.set(session.messageId, warningId);
253
- } catch (e) {
254
- ctx.logger.warn("[消息] 发送重复警告失败:", e);
255
- }
256
- }
257
- return;
258
- }
255
+ if (!/神金|发|w/i.test(preprocessedMessage) && parsedEvent.dateTime === "时间未知") return;
259
256
  const biliInfo = await fetchBilibiliInfo(ctx, roomId);
260
257
  if (!biliInfo) return;
261
258
  let helperMessageId;
262
259
  if (groupConfig.sendHelperMessages) {
263
- [helperMessageId] = await session.send(`直播间: ${roomId}
260
+ try {
261
+ [helperMessageId] = await session.send(`直播间: ${roomId}
264
262
  投稿数: ${biliInfo.videoCount}`);
263
+ } catch (e) {
264
+ ctx.logger.warn("[消息] 发送辅助信息失败:", e);
265
+ }
266
+ }
267
+ const { dateTime } = parsedEvent;
268
+ if (forwardedHistory.some((entry) => entry.roomId === roomId && entry.dateTime === dateTime)) {
269
+ ctx.logger.info(`[防复读] 检测到重复活动,已发送辅助信息,跳过转发: 房间=${roomId}, 时间=${dateTime}`);
270
+ return;
265
271
  }
266
272
  try {
267
273
  const forwardMessage = `${session.content}
@@ -269,24 +275,20 @@ function apply(ctx, config) {
269
275
  ---
270
276
  投稿数: ${biliInfo.videoCount}`;
271
277
  const [forwardedMessageId] = config.isGroup ? await session.bot.sendMessage(config.targetQQ, forwardMessage) : await session.bot.sendPrivateMessage(config.targetQQ, forwardMessage);
272
- forwardedHistory.push({
273
- originalMessageId: session.messageId,
274
- forwardedMessageId,
275
- helperMessageId,
276
- // helperMessageId 可能是 undefined
277
- roomId,
278
- dateTime
279
- });
278
+ forwardedHistory.push({ originalMessageId: session.messageId, forwardedMessageId, helperMessageId, roomId, dateTime });
280
279
  if (forwardedHistory.length > config.historySize) forwardedHistory.shift();
281
- await sendBilibiliDanmaku(ctx, config, roomId, "喵喵喵");
280
+ if (config.biliAccessKeys?.length > 0) {
281
+ ctx.logger.info(`[弹幕] 准备为 ${config.biliAccessKeys.length} 个账号发送弹幕到直播间 ${roomId}...`);
282
+ const danmakuPromises = config.biliAccessKeys.map((keyConfig) => sendBilibiliDanmaku(ctx, keyConfig, roomId, "喵喵喵"));
283
+ Promise.allSettled(danmakuPromises);
284
+ }
282
285
  } catch (error) {
283
286
  session.send("🐱 - 转发失败,请检查目标QQ/群号配置是否正确");
284
287
  ctx.logger.error("[转发] 失败:", error);
285
288
  }
286
289
  });
287
290
  ctx.on("message-deleted", async (session) => {
288
- const isMonitored = config.monitorGroups.some((g) => g.groupId === session.channelId);
289
- if (!isMonitored) return;
291
+ if (!config.monitorGroups.some((g) => g.groupId === session.channelId)) return;
290
292
  const originalMessageId = session.messageId;
291
293
  const entryIndex = forwardedHistory.findIndex((entry) => entry.originalMessageId === originalMessageId);
292
294
  if (entryIndex !== -1) {
@@ -299,7 +301,8 @@ function apply(ctx, config) {
299
301
  }
300
302
  }
301
303
  try {
302
- await session.bot.deleteMessage(config.targetQQ, entry.forwardedMessageId);
304
+ const targetChannel = config.isGroup ? config.targetQQ : `private:${config.targetQQ}`;
305
+ await session.bot.deleteMessage(targetChannel, entry.forwardedMessageId);
303
306
  } catch (e) {
304
307
  ctx.logger.warn(`[撤回] 转发消息 (ID: ${entry.forwardedMessageId}) 失败:`, e);
305
308
  } finally {
@@ -307,17 +310,6 @@ function apply(ctx, config) {
307
310
  ctx.logger.info(`[撤回] 已联动撤回与源消息 ${originalMessageId} 相关的转发。`);
308
311
  }
309
312
  }
310
- if (warningMessageMap.has(originalMessageId)) {
311
- const warningMessageId = warningMessageMap.get(originalMessageId);
312
- try {
313
- await session.bot.deleteMessage(session.channelId, warningMessageId);
314
- } catch (e) {
315
- ctx.logger.warn(`[撤回] 警告消息 (ID: ${warningMessageId}) 失败:`, e);
316
- } finally {
317
- warningMessageMap.delete(originalMessageId);
318
- ctx.logger.info(`[撤回] 已联动撤回与源消息 ${originalMessageId} 相关的警告。`);
319
- }
320
- }
321
313
  });
322
314
  }
323
315
  __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.0.1",
4
+ "version": "1.2.0",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [