@nextclaw/channel-runtime 0.1.21 → 0.1.23

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NextClaw contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.d.ts CHANGED
@@ -175,14 +175,32 @@ declare class QQChannel extends BaseChannel<Config["channels"]["qq"]> {
175
175
  private bot;
176
176
  private processedIds;
177
177
  private processedSet;
178
+ private reconnectTimer;
179
+ private connectTask;
180
+ private reconnectAttempt;
181
+ private readonly reconnectBaseMs;
182
+ private readonly reconnectMaxMs;
178
183
  constructor(config: Config["channels"]["qq"], bus: MessageBus);
179
184
  start(): Promise<void>;
180
185
  stop(): Promise<void>;
181
186
  send(msg: OutboundMessage): Promise<void>;
182
187
  private handleIncoming;
188
+ private resolveSenderName;
189
+ private decorateSpeakerPrefix;
190
+ private sanitizeSpeakerToken;
183
191
  private isDuplicate;
184
192
  private sendWithTokenRetry;
185
193
  private isTokenExpiredError;
194
+ private tryConnect;
195
+ private connect;
196
+ private createBot;
197
+ private handleSessionDead;
198
+ private scheduleReconnect;
199
+ private clearReconnectTimer;
200
+ private teardownBot;
201
+ private safeStopBot;
202
+ private getBackoffDelayMs;
203
+ private formatError;
186
204
  }
187
205
 
188
206
  declare class SlackChannel extends BaseChannel<Config["channels"]["slack"]> {
@@ -219,6 +237,7 @@ declare class TelegramChannel extends BaseChannel<Config["channels"]["telegram"]
219
237
  private startTyping;
220
238
  private stopTyping;
221
239
  private resolveAccountId;
240
+ private maybeAddAckReaction;
222
241
  private isAllowedByPolicy;
223
242
  private resolveMentionState;
224
243
  }
package/dist/index.js CHANGED
@@ -2414,6 +2414,7 @@ function sleep3(ms) {
2414
2414
  import {
2415
2415
  Bot,
2416
2416
  ReceiverMode,
2417
+ SessionEvents,
2417
2418
  segment
2418
2419
  } from "qq-official-bot";
2419
2420
  var QQChannel = class extends BaseChannel {
@@ -2421,38 +2422,31 @@ var QQChannel = class extends BaseChannel {
2421
2422
  bot = null;
2422
2423
  processedIds = [];
2423
2424
  processedSet = /* @__PURE__ */ new Set();
2425
+ reconnectTimer = null;
2426
+ connectTask = null;
2427
+ reconnectAttempt = 0;
2428
+ reconnectBaseMs = 1e3;
2429
+ reconnectMaxMs = 6e4;
2424
2430
  constructor(config, bus) {
2425
2431
  super(config, bus);
2426
2432
  }
2427
2433
  async start() {
2428
- this.running = true;
2429
2434
  if (!this.config.appId || !this.config.secret) {
2435
+ this.running = false;
2430
2436
  throw new Error("QQ appId/appSecret not configured");
2431
2437
  }
2432
- this.bot = new Bot({
2433
- appid: this.config.appId,
2434
- secret: this.config.secret,
2435
- mode: ReceiverMode.WEBSOCKET,
2436
- intents: ["C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE"],
2437
- removeAt: true,
2438
- logLevel: "info"
2439
- });
2440
- this.bot.on("message.private", async (event) => {
2441
- await this.handleIncoming(event);
2442
- });
2443
- this.bot.on("message.group", async (event) => {
2444
- await this.handleIncoming(event);
2445
- });
2446
- await this.bot.start();
2447
- console.log("QQ bot connected");
2438
+ this.running = true;
2439
+ this.reconnectAttempt = 0;
2440
+ this.clearReconnectTimer();
2441
+ this.tryConnect("startup");
2448
2442
  }
2449
2443
  async stop() {
2450
2444
  this.running = false;
2451
- if (this.bot) {
2452
- this.bot.removeAllListeners("message.private");
2453
- this.bot.removeAllListeners("message.group");
2454
- await this.bot.stop();
2455
- this.bot = null;
2445
+ this.clearReconnectTimer();
2446
+ this.reconnectAttempt = 0;
2447
+ await this.teardownBot();
2448
+ if (this.connectTask) {
2449
+ await this.connectTask;
2456
2450
  }
2457
2451
  }
2458
2452
  async send(msg) {
@@ -2497,7 +2491,8 @@ var QQChannel = class extends BaseChannel {
2497
2491
  return;
2498
2492
  }
2499
2493
  const content = event.raw_message?.trim() ?? "";
2500
- const safeContent = content || "[empty message]";
2494
+ const normalizedContent = content || "[empty message]";
2495
+ const senderName = this.resolveSenderName(rawEvent);
2501
2496
  let chatId = senderId;
2502
2497
  let messageType = "private";
2503
2498
  const qqMeta = {};
@@ -2507,21 +2502,36 @@ var QQChannel = class extends BaseChannel {
2507
2502
  chatId = groupId;
2508
2503
  qqMeta.groupId = groupId;
2509
2504
  qqMeta.userId = senderId;
2505
+ if (senderName) {
2506
+ qqMeta.userName = senderName;
2507
+ }
2510
2508
  } else if (event.message_type === "guild") {
2511
2509
  messageType = "guild";
2512
2510
  chatId = event.channel_id ?? "";
2513
2511
  qqMeta.guildId = event.guild_id;
2514
2512
  qqMeta.channelId = event.channel_id;
2515
2513
  qqMeta.userId = senderId;
2514
+ if (senderName) {
2515
+ qqMeta.userName = senderName;
2516
+ }
2516
2517
  } else if (event.sub_type === "direct") {
2517
2518
  messageType = "direct";
2518
2519
  chatId = event.guild_id ?? "";
2519
2520
  qqMeta.guildId = event.guild_id;
2520
2521
  qqMeta.userId = senderId;
2522
+ if (senderName) {
2523
+ qqMeta.userName = senderName;
2524
+ }
2521
2525
  } else {
2522
2526
  qqMeta.userId = senderId;
2523
2527
  }
2524
2528
  qqMeta.messageType = messageType;
2529
+ const safeContent = this.decorateSpeakerPrefix({
2530
+ content: normalizedContent,
2531
+ messageType,
2532
+ senderId,
2533
+ senderName
2534
+ });
2525
2535
  if (!chatId) {
2526
2536
  return;
2527
2537
  }
@@ -2539,6 +2549,42 @@ var QQChannel = class extends BaseChannel {
2539
2549
  }
2540
2550
  });
2541
2551
  }
2552
+ resolveSenderName(rawEvent) {
2553
+ const candidates = [
2554
+ rawEvent.sender?.card,
2555
+ rawEvent.sender?.nickname,
2556
+ rawEvent.sender?.nick,
2557
+ rawEvent.sender?.username
2558
+ ];
2559
+ for (const value of candidates) {
2560
+ if (typeof value !== "string") {
2561
+ continue;
2562
+ }
2563
+ const normalized = value.trim();
2564
+ if (normalized) {
2565
+ return normalized;
2566
+ }
2567
+ }
2568
+ return null;
2569
+ }
2570
+ decorateSpeakerPrefix(params) {
2571
+ if (params.messageType !== "group" && params.messageType !== "guild") {
2572
+ return params.content;
2573
+ }
2574
+ const userId = this.sanitizeSpeakerToken(params.senderId);
2575
+ if (!userId) {
2576
+ return params.content;
2577
+ }
2578
+ const name = this.sanitizeSpeakerToken(params.senderName ?? "");
2579
+ const speakerFields = [`user_id=${userId}`];
2580
+ if (name) {
2581
+ speakerFields.push(`name=${name}`);
2582
+ }
2583
+ return `[speaker:${speakerFields.join(";")}] ${params.content}`;
2584
+ }
2585
+ sanitizeSpeakerToken(value) {
2586
+ return value.replace(/[\r\n;\]]/g, " ").trim();
2587
+ }
2542
2588
  isDuplicate(messageId) {
2543
2589
  if (this.processedSet.has(messageId)) {
2544
2590
  return true;
@@ -2568,6 +2614,117 @@ var QQChannel = class extends BaseChannel {
2568
2614
  const message = error instanceof Error ? error.message : String(error);
2569
2615
  return message.includes("code(11244)") || message.toLowerCase().includes("token not exist or expire");
2570
2616
  }
2617
+ tryConnect(trigger) {
2618
+ if (!this.running || this.bot || this.connectTask) {
2619
+ return;
2620
+ }
2621
+ this.connectTask = this.connect(trigger).finally(() => {
2622
+ this.connectTask = null;
2623
+ });
2624
+ }
2625
+ async connect(trigger) {
2626
+ let candidate = null;
2627
+ try {
2628
+ candidate = this.createBot();
2629
+ await candidate.start();
2630
+ if (!this.running) {
2631
+ await this.safeStopBot(candidate);
2632
+ return;
2633
+ }
2634
+ this.bot = candidate;
2635
+ this.reconnectAttempt = 0;
2636
+ console.log("QQ bot connected");
2637
+ } catch (error) {
2638
+ if (candidate) {
2639
+ await this.safeStopBot(candidate);
2640
+ }
2641
+ if (!this.running) {
2642
+ return;
2643
+ }
2644
+ this.reconnectAttempt += 1;
2645
+ const delayMs = this.getBackoffDelayMs(this.reconnectAttempt);
2646
+ console.error(
2647
+ `[qq] start failed (${trigger}, attempt ${this.reconnectAttempt}), retry in ${delayMs}ms: ${this.formatError(error)}`
2648
+ );
2649
+ this.scheduleReconnect(delayMs, `${trigger}-retry`);
2650
+ }
2651
+ }
2652
+ createBot() {
2653
+ const bot = new Bot({
2654
+ appid: this.config.appId,
2655
+ secret: this.config.secret,
2656
+ mode: ReceiverMode.WEBSOCKET,
2657
+ intents: ["C2C_MESSAGE_CREATE", "GROUP_AT_MESSAGE_CREATE"],
2658
+ removeAt: true,
2659
+ logLevel: "info"
2660
+ });
2661
+ bot.on("message.private", async (event) => {
2662
+ await this.handleIncoming(event);
2663
+ });
2664
+ bot.on("message.group", async (event) => {
2665
+ await this.handleIncoming(event);
2666
+ });
2667
+ bot.sessionManager.on(SessionEvents.DEAD, () => {
2668
+ void this.handleSessionDead(bot);
2669
+ });
2670
+ return bot;
2671
+ }
2672
+ async handleSessionDead(bot) {
2673
+ if (!this.running || this.bot !== bot) {
2674
+ return;
2675
+ }
2676
+ this.bot = null;
2677
+ await this.safeStopBot(bot);
2678
+ this.reconnectAttempt += 1;
2679
+ const delayMs = this.getBackoffDelayMs(this.reconnectAttempt);
2680
+ console.error(`[qq] session dead, reconnect in ${delayMs}ms`);
2681
+ this.scheduleReconnect(delayMs, "session-dead");
2682
+ }
2683
+ scheduleReconnect(delayMs, trigger) {
2684
+ if (!this.running) {
2685
+ return;
2686
+ }
2687
+ this.clearReconnectTimer();
2688
+ this.reconnectTimer = setTimeout(() => {
2689
+ this.reconnectTimer = null;
2690
+ this.tryConnect(trigger);
2691
+ }, delayMs);
2692
+ }
2693
+ clearReconnectTimer() {
2694
+ if (!this.reconnectTimer) {
2695
+ return;
2696
+ }
2697
+ clearTimeout(this.reconnectTimer);
2698
+ this.reconnectTimer = null;
2699
+ }
2700
+ async teardownBot() {
2701
+ if (!this.bot) {
2702
+ return;
2703
+ }
2704
+ const bot = this.bot;
2705
+ this.bot = null;
2706
+ await this.safeStopBot(bot);
2707
+ }
2708
+ async safeStopBot(bot) {
2709
+ bot.removeAllListeners("message.private");
2710
+ bot.removeAllListeners("message.group");
2711
+ bot.sessionManager.removeAllListeners(SessionEvents.DEAD);
2712
+ try {
2713
+ await bot.stop();
2714
+ } catch {
2715
+ }
2716
+ }
2717
+ getBackoffDelayMs(attempt) {
2718
+ const jitter = Math.floor(Math.random() * 500);
2719
+ const exp = Math.min(this.reconnectMaxMs, this.reconnectBaseMs * 2 ** Math.max(0, attempt - 1));
2720
+ return Math.min(this.reconnectMaxMs, exp + jitter);
2721
+ }
2722
+ formatError(error) {
2723
+ if (error instanceof Error) {
2724
+ return error.stack ?? error.message;
2725
+ }
2726
+ return String(error);
2727
+ }
2571
2728
  };
2572
2729
 
2573
2730
  // src/channels/slack.ts
@@ -2988,6 +3145,12 @@ Just send me a text message to chat!`;
2988
3145
  contentParts.push(`[${mediaType}: ${finalPath}]`);
2989
3146
  }
2990
3147
  }
3148
+ await this.maybeAddAckReaction({
3149
+ message,
3150
+ chatId,
3151
+ isGroup,
3152
+ mentionState
3153
+ });
2991
3154
  const content = contentParts.length ? contentParts.join("\n") : "[empty message]";
2992
3155
  this.startTyping(chatId);
2993
3156
  try {
@@ -3024,6 +3187,35 @@ Just send me a text message to chat!`;
3024
3187
  const accountId = this.config.accountId?.trim();
3025
3188
  return accountId || "default";
3026
3189
  }
3190
+ async maybeAddAckReaction(params) {
3191
+ if (!this.bot) {
3192
+ return;
3193
+ }
3194
+ if (typeof params.message.message_id !== "number") {
3195
+ return;
3196
+ }
3197
+ const emoji = (this.config.ackReaction ?? "\u{1F440}").trim();
3198
+ if (!emoji) {
3199
+ return;
3200
+ }
3201
+ const shouldAck = shouldSendAckReaction({
3202
+ scope: this.config.ackReactionScope,
3203
+ isDirect: !params.isGroup,
3204
+ isGroup: params.isGroup,
3205
+ requireMention: params.mentionState.requireMention,
3206
+ wasMentioned: params.mentionState.wasMentioned
3207
+ });
3208
+ if (!shouldAck) {
3209
+ return;
3210
+ }
3211
+ const reaction = [{ type: "emoji", emoji }];
3212
+ try {
3213
+ await this.bot.setMessageReaction(Number(params.chatId), params.message.message_id, {
3214
+ reaction
3215
+ });
3216
+ } catch {
3217
+ }
3218
+ }
3027
3219
  isAllowedByPolicy(params) {
3028
3220
  if (!params.isGroup) {
3029
3221
  if (this.config.dmPolicy === "disabled") {
@@ -3150,6 +3342,25 @@ function inferMediaMimeType(mediaType) {
3150
3342
  }
3151
3343
  return void 0;
3152
3344
  }
3345
+ function shouldSendAckReaction(params) {
3346
+ const scope = params.scope ?? "all";
3347
+ if (scope === "off") {
3348
+ return false;
3349
+ }
3350
+ if (scope === "all") {
3351
+ return true;
3352
+ }
3353
+ if (scope === "direct") {
3354
+ return params.isDirect;
3355
+ }
3356
+ if (scope === "group-all") {
3357
+ return params.isGroup;
3358
+ }
3359
+ if (scope === "group-mentions") {
3360
+ return params.isGroup && params.requireMention && params.wasMentioned;
3361
+ }
3362
+ return false;
3363
+ }
3153
3364
  function markdownToTelegramHtml(text) {
3154
3365
  if (!text) {
3155
3366
  return "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/channel-runtime",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "private": false,
5
5
  "description": "Runtime implementations for NextClaw builtin channel plugins.",
6
6
  "type": "module",
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "dependencies": {
17
17
  "@larksuiteoapi/node-sdk": "^1.58.0",
18
- "@nextclaw/core": "^0.6.38",
18
+ "@nextclaw/core": "^0.6.40",
19
19
  "@slack/socket-mode": "^1.3.3",
20
20
  "@slack/web-api": "^7.6.0",
21
21
  "dingtalk-stream": "^2.1.4",