@zhin.js/adapter-napcat 0.1.13 → 0.1.15

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 (67) hide show
  1. package/CHANGELOG.md +36 -13
  2. package/README.md +121 -0
  3. package/client/NapCatManagement.tsx +113 -0
  4. package/client/index.tsx +11 -0
  5. package/client/tsconfig.json +7 -0
  6. package/client/utils/api.ts +30 -0
  7. package/dist/index.js +27 -0
  8. package/lib/adapter.d.ts +3 -0
  9. package/lib/adapter.d.ts.map +1 -1
  10. package/lib/adapter.js +5 -2
  11. package/lib/adapter.js.map +1 -1
  12. package/lib/agent-prompt.d.ts +3 -0
  13. package/lib/agent-prompt.d.ts.map +1 -0
  14. package/lib/agent-prompt.js +107 -0
  15. package/lib/agent-prompt.js.map +1 -0
  16. package/lib/bot-base.d.ts +6 -1
  17. package/lib/bot-base.d.ts.map +1 -1
  18. package/lib/bot-base.js +35 -1
  19. package/lib/bot-base.js.map +1 -1
  20. package/lib/bot-http.d.ts +2 -1
  21. package/lib/bot-http.d.ts.map +1 -1
  22. package/lib/bot-http.js +23 -1
  23. package/lib/bot-http.js.map +1 -1
  24. package/lib/bot-ws-client.d.ts +2 -0
  25. package/lib/bot-ws-client.d.ts.map +1 -1
  26. package/lib/bot-ws-client.js +32 -0
  27. package/lib/bot-ws-client.js.map +1 -1
  28. package/lib/bot-ws-server.d.ts +2 -1
  29. package/lib/bot-ws-server.d.ts.map +1 -1
  30. package/lib/bot-ws-server.js +14 -0
  31. package/lib/bot-ws-server.js.map +1 -1
  32. package/lib/index.d.ts +5 -1
  33. package/lib/index.d.ts.map +1 -1
  34. package/lib/index.js +22 -1
  35. package/lib/index.js.map +1 -1
  36. package/lib/napcat-inbound.d.ts +21 -0
  37. package/lib/napcat-inbound.d.ts.map +1 -0
  38. package/lib/napcat-inbound.js +55 -0
  39. package/lib/napcat-inbound.js.map +1 -0
  40. package/lib/onebot-get-msg.d.ts +3 -0
  41. package/lib/onebot-get-msg.d.ts.map +1 -0
  42. package/lib/onebot-get-msg.js +27 -0
  43. package/lib/onebot-get-msg.js.map +1 -0
  44. package/lib/routes.d.ts +4 -0
  45. package/lib/routes.d.ts.map +1 -0
  46. package/lib/routes.js +61 -0
  47. package/lib/routes.js.map +1 -0
  48. package/lib/types.d.ts +18 -0
  49. package/lib/types.d.ts.map +1 -1
  50. package/lib/typing-indicator.d.ts +45 -0
  51. package/lib/typing-indicator.d.ts.map +1 -0
  52. package/lib/typing-indicator.js +132 -0
  53. package/lib/typing-indicator.js.map +1 -0
  54. package/package.json +22 -6
  55. package/plugin.yml +1 -1
  56. package/src/adapter.ts +3 -3
  57. package/src/agent-prompt.ts +115 -0
  58. package/src/bot-base.ts +36 -1
  59. package/src/bot-http.ts +19 -1
  60. package/src/bot-ws-client.ts +34 -0
  61. package/src/bot-ws-server.ts +16 -1
  62. package/src/index.ts +41 -2
  63. package/src/napcat-inbound.ts +57 -0
  64. package/src/onebot-get-msg.ts +33 -0
  65. package/src/routes.ts +61 -0
  66. package/src/types.ts +13 -0
  67. package/src/typing-indicator.ts +183 -0
@@ -0,0 +1,115 @@
1
+ import type {
2
+ AgentPromptBuildContext,
3
+ AgentPromptContributor,
4
+ AgentPromptSection,
5
+ AgentTool,
6
+ } from 'zhin.js';
7
+ import { filterTools } from 'zhin.js';
8
+
9
+ function isNapCatDelegatedTask(query: string, goal: string): boolean {
10
+ const text = `${query} ${goal}`;
11
+ if (/\bnapcat\b|napcat_|mcp_napcat/i.test(text)) return true;
12
+ if (/戳一戳|poke|表情回应|emoji.*reaction/i.test(text)) return true;
13
+ if (/精华消息|essence|群公告|group.*notice/i.test(text)) return true;
14
+ if (/AI语音|ai.*record|tts|文字转语音/i.test(text)) return true;
15
+ if (/群文件|upload.*file|群签到|group.*sign/i.test(text)) return true;
16
+ if (/合并转发|forward.*msg|转发消息/i.test(text)) return true;
17
+ if (/点赞/.test(text) && /qq|好友|\d{5,}/i.test(text)) return true;
18
+ return false;
19
+ }
20
+
21
+ function selectNapCatDeferredTools(
22
+ query: string,
23
+ goal: string,
24
+ deferredCatalog: AgentTool[],
25
+ maxTools: number,
26
+ ): AgentTool[] {
27
+ const pool = deferredCatalog.filter(
28
+ t => !t.name.startsWith('mcp_filesystem') && !t.name.startsWith('mcp_memory_'),
29
+ );
30
+ const napcatTools = pool.filter(t => t.name.startsWith('napcat_'));
31
+ const pinned: AgentTool[] = [];
32
+ const preferOrder = [
33
+ 'napcat_send_poke',
34
+ 'napcat_set_emoji_reaction',
35
+ 'napcat_send_like',
36
+ 'napcat_set_essence_msg',
37
+ 'napcat_send_group_notice',
38
+ 'napcat_ai_tts',
39
+ ];
40
+ for (const name of preferOrder) {
41
+ const t = napcatTools.find(x => x.name === name);
42
+ if (t) pinned.push(t);
43
+ }
44
+ for (const t of napcatTools) {
45
+ if (pinned.length >= maxTools) break;
46
+ if (!pinned.some(p => p.name === t.name)) pinned.push(t);
47
+ }
48
+
49
+ const extra = filterTools(query, pool, { maxTools, minScore: 0.08 })
50
+ .filter(t => !pinned.some(p => p.name === t.name));
51
+
52
+ const merged: AgentTool[] = [...pinned];
53
+ for (const t of extra) {
54
+ if (merged.length >= maxTools) break;
55
+ merged.push(t);
56
+ }
57
+ return merged.slice(0, maxTools);
58
+ }
59
+
60
+ const ORCHESTRATOR_NAPCAT = [
61
+ 'On napcat/QQ: use run_deferred_task with tool_query "napcat_send_poke" or "napcat_set_emoji_reaction".',
62
+ 'NapCat supports: poke, emoji reaction, forward msg, essence, group notice, AI TTS, OCR, group files.',
63
+ 'Skip tool_search when the user clearly asks for QQ-specific features like poke, reaction, or group management.',
64
+ ].join('\n');
65
+
66
+ const WORKER_NAPCAT = [
67
+ '- Use `napcat_send_poke` for poke/戳一戳.',
68
+ '- Use `napcat_set_emoji_reaction` for emoji reaction/表情回应.',
69
+ '- Use `napcat_send_like` for friend like/点赞.',
70
+ '- Use `napcat_ai_tts` for AI voice/AI语音.',
71
+ '- Use `napcat_set_essence_msg` / `napcat_delete_essence_msg` for essence messages.',
72
+ '- Use `napcat_send_group_notice` for group announcements.',
73
+ '- Do NOT use mcp_filesystem_* or explore node_modules for QQ tasks.',
74
+ ].join('\n');
75
+
76
+ export function createNapCatAgentPromptContributor(): AgentPromptContributor {
77
+ return {
78
+ platform: 'napcat',
79
+
80
+ async buildSections(ctx: AgentPromptBuildContext): Promise<AgentPromptSection[] | null> {
81
+ if (ctx.slot === 'orchestrator') {
82
+ if (!ctx.toolSearch) return null;
83
+ return [{
84
+ id: 'platform.napcat.orchestrator',
85
+ title: '## napcat / QQ',
86
+ body: ORCHESTRATOR_NAPCAT,
87
+ priority: 50,
88
+ }];
89
+ }
90
+ if (ctx.slot === 'deferred_worker') {
91
+ const query = ctx.deferred?.toolQuery ?? ctx.deferred?.goal ?? '';
92
+ const goal = ctx.deferred?.goal ?? '';
93
+ if (!isNapCatDelegatedTask(query, goal)) return null;
94
+ return [{
95
+ id: 'platform.napcat.deferred_worker',
96
+ title: '## napcat / QQ(本任务)',
97
+ body: WORKER_NAPCAT,
98
+ priority: 50,
99
+ }];
100
+ }
101
+ return null;
102
+ },
103
+
104
+ matchesDeferredTask(ctx: AgentPromptBuildContext): boolean {
105
+ const query = ctx.deferred?.toolQuery ?? ctx.deferred?.goal ?? ctx.userMessagePreview ?? '';
106
+ const goal = ctx.deferred?.goal ?? ctx.userMessagePreview ?? '';
107
+ return isNapCatDelegatedTask(query, goal);
108
+ },
109
+
110
+ selectDeferredTools(query, goal, catalog, maxTools) {
111
+ if (!isNapCatDelegatedTask(query, goal)) return null;
112
+ return selectNapCatDeferredTools(query, goal, catalog, maxTools);
113
+ },
114
+ };
115
+ }
package/src/bot-base.ts CHANGED
@@ -10,12 +10,16 @@ import {
10
10
  segment,
11
11
  Notice,
12
12
  Request,
13
+ type QuotedMessagePayload,
13
14
  } from 'zhin.js';
15
+ import { parseOneBotGetMsgResponse } from './onebot-get-msg.js';
14
16
  import type { NapCatBotConfig, NapCatMessageEvent, MessageSegment, ApiResponse } from './types.js';
15
17
  import type { NapCatAdapter } from './adapter.js';
18
+ import { InboundMessageDeduper, isSelfMessage, normalizeMessage, resolveSideEventDedupeKey } from './napcat-inbound.js';
16
19
 
17
20
  export abstract class NapCatBotBase extends EventEmitter implements Bot<NapCatBotConfig, NapCatMessageEvent> {
18
21
  $connected = false;
22
+ protected readonly inboundDeduper = new InboundMessageDeduper();
19
23
 
20
24
  get logger() { return this.adapter.plugin.logger; }
21
25
  get $id() { return this.$config.name; }
@@ -33,6 +37,10 @@ export abstract class NapCatBotBase extends EventEmitter implements Bot<NapCatBo
33
37
  // ══════════════════════════════════════════════════════════════════
34
38
 
35
39
  $formatMessage(ev: NapCatMessageEvent): Message<NapCatMessageEvent> {
40
+ const content = normalizeMessage(ev.message);
41
+ const quoteId = Message.quoteIdFromContent(content);
42
+ Message.alignReplySegments(content, quoteId);
43
+
36
44
  const message = Message.from(ev, {
37
45
  $id: ev.message_id.toString(),
38
46
  $adapter: 'napcat',
@@ -46,7 +54,8 @@ export abstract class NapCatBotBase extends EventEmitter implements Bot<NapCatBo
46
54
  id: (ev.group_id || ev.user_id).toString(),
47
55
  type: ev.group_id ? 'group' : 'private',
48
56
  },
49
- $content: ev.message,
57
+ $content: content,
58
+ $quote_id: quoteId,
50
59
  $raw: ev.raw_message,
51
60
  $timestamp: ev.time,
52
61
  $recall: async () => { await this.deleteMsg(ev.message_id); },
@@ -83,6 +92,18 @@ export abstract class NapCatBotBase extends EventEmitter implements Bot<NapCatBo
83
92
  await this.deleteMsg(parseInt(id));
84
93
  }
85
94
 
95
+ async $addReaction(messageId: string, emojiId: string): Promise<string> {
96
+ await this.setMsgEmojiLike(parseInt(messageId), emojiId);
97
+ return `reaction:${messageId}:${emojiId}`;
98
+ }
99
+
100
+ async $removeReaction(messageId: string, _reactionId: string): Promise<void> {
101
+ // NapCat 的 set_msg_emoji_like 是 toggle 行为,再调一次即取消
102
+ const parts = _reactionId.split(':');
103
+ const emojiId = parts[2] || parts[0];
104
+ await this.setMsgEmojiLike(parseInt(messageId), emojiId);
105
+ }
106
+
86
107
  // ══════════════════════════════════════════════════════════════════
87
108
  // 事件分发
88
109
  // ══════════════════════════════════════════════════════════════════
@@ -102,12 +123,18 @@ export abstract class NapCatBotBase extends EventEmitter implements Bot<NapCatBo
102
123
  }
103
124
 
104
125
  private handleMessage(ev: NapCatMessageEvent): void {
126
+ if (isSelfMessage(ev)) return;
127
+ const msgId = ev.message_id.toString();
128
+ if (!this.inboundDeduper.shouldProcess(msgId)) return;
129
+ ev.message = normalizeMessage(ev.message);
105
130
  const message = this.$formatMessage(ev);
106
131
  this.adapter.emit('message.receive', message);
107
132
  this.logger.debug(`${this.$id} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`);
108
133
  }
109
134
 
110
135
  private handleNotice(event: any): void {
136
+ const dedupeKey = resolveSideEventDedupeKey(event, 'notice');
137
+ if (!this.inboundDeduper.shouldProcess(dedupeKey)) return;
111
138
  const noticeTypeMap: Record<string, string> = {
112
139
  group_increase: 'group_member_increase',
113
140
  group_decrease: 'group_member_decrease',
@@ -152,6 +179,8 @@ export abstract class NapCatBotBase extends EventEmitter implements Bot<NapCatBo
152
179
  }
153
180
 
154
181
  private handleRequest(event: any): void {
182
+ const dedupeKey = resolveSideEventDedupeKey(event, 'request');
183
+ if (!this.inboundDeduper.shouldProcess(dedupeKey)) return;
155
184
  const typeMap: Record<string, string> = {
156
185
  friend: 'friend_add',
157
186
  group: event.sub_type === 'invite' ? 'group_invite' : 'group_add',
@@ -199,6 +228,12 @@ export abstract class NapCatBotBase extends EventEmitter implements Bot<NapCatBo
199
228
  }
200
229
  async deleteMsg(messageId: number) { return this.callApi('delete_msg', { message_id: messageId }); }
201
230
  async getMsg(messageId: number) { return this.callApi('get_msg', { message_id: messageId }); }
231
+
232
+ async $getMsg(messageId: string): Promise<QuotedMessagePayload> {
233
+ const idParam = /^\d+$/.test(messageId) ? Number(messageId) : messageId;
234
+ const data = await this.callApi('get_msg', { message_id: idParam });
235
+ return parseOneBotGetMsgResponse(messageId, data);
236
+ }
202
237
  async getForwardMsg(id: string) { return this.callApi('get_forward_msg', { id }); }
203
238
  async sendLike(userId: number, times = 1) { return this.callApi('send_like', { user_id: userId, times }); }
204
239
 
package/src/bot-http.ts CHANGED
@@ -7,8 +7,9 @@ import { formatCompact } from 'zhin.js';
7
7
  import { NapCatBotBase } from './bot-base.js';
8
8
  import type { NapCatHttpConfig, ApiResponse } from './types.js';
9
9
  import type { NapCatAdapter } from './adapter.js';
10
- import { registerFetchRoute, type Router, type RouterContext } from '@zhin.js/http/router';
10
+ import { registerFetchRoute, type Router, type RouterContext } from '@zhin.js/host-router/router';
11
11
  import * as crypto from 'crypto';
12
+ import { enableTypingIndicator } from './typing-indicator.js';
12
13
 
13
14
  export class NapCatHttpBot extends NapCatBotBase {
14
15
  private pollTimer?: NodeJS.Timeout;
@@ -24,11 +25,13 @@ export class NapCatHttpBot extends NapCatBotBase {
24
25
  await this.checkConnection();
25
26
  this.startPoll();
26
27
  this.$connected = true;
28
+ this.initTypingIndicator();
27
29
  this.logger.info(formatCompact({ bot: this.$id, mode: 'http' }));
28
30
  }
29
31
 
30
32
  async $disconnect(): Promise<void> {
31
33
  if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = undefined; }
34
+ this.inboundDeduper.clear();
32
35
  this.$connected = false;
33
36
  }
34
37
 
@@ -54,9 +57,12 @@ export class NapCatHttpBot extends NapCatBotBase {
54
57
 
55
58
  if (this.$config.access_token) {
56
59
  const sig = ctx.get('x-signature');
60
+ const authHeader = ctx.get('authorization');
57
61
  if (sig) {
58
62
  const expected = 'sha1=' + crypto.createHmac('sha1', this.$config.access_token).update(JSON.stringify(body)).digest('hex');
59
63
  if (sig !== expected) { ctx.status = 403; ctx.body = { error: 'signature mismatch' }; return; }
64
+ } else if (authHeader) {
65
+ if (authHeader !== `Bearer ${this.$config.access_token}`) { ctx.status = 403; ctx.body = { error: 'auth failed' }; return; }
60
66
  }
61
67
  }
62
68
 
@@ -88,4 +94,16 @@ export class NapCatHttpBot extends NapCatBotBase {
88
94
  }
89
95
  }, interval);
90
96
  }
97
+
98
+ private initTypingIndicator(): void {
99
+ const tiConfig = (this.$config as any).typingIndicator;
100
+ if (tiConfig && tiConfig.enabled !== false) {
101
+ enableTypingIndicator(this, {
102
+ enabled: true,
103
+ defaultEmoji: tiConfig.defaultEmoji || '128516',
104
+ autoRemove: true,
105
+ removeDelay: 5000,
106
+ });
107
+ }
108
+ }
91
109
  }
@@ -6,6 +6,7 @@ import { formatCompact } from 'zhin.js';
6
6
  import { NapCatBotBase } from './bot-base.js';
7
7
  import type { NapCatWsClientConfig, ApiResponse } from './types.js';
8
8
  import type { NapCatAdapter } from './adapter.js';
9
+ import { enableTypingIndicator } from './typing-indicator.js';
9
10
 
10
11
  export class NapCatWsClient extends NapCatBotBase {
11
12
  private ws?: WebSocket;
@@ -43,6 +44,7 @@ export class NapCatWsClient extends NapCatBotBase {
43
44
  }
44
45
  this.logger.info(formatCompact({ bot: this.$id, mode: 'ws' }));
45
46
  this.startHeartbeat();
47
+ this.initTypingIndicator();
46
48
  resolve();
47
49
  });
48
50
 
@@ -87,6 +89,7 @@ export class NapCatWsClient extends NapCatBotBase {
87
89
  for (const [, req] of this.pendingRequests) { clearTimeout(req.timeout); req.reject(new Error('Connection closed')); }
88
90
  this.pendingRequests.clear();
89
91
  if (this.ws) { this.ws.close(); this.ws = undefined; }
92
+ this.inboundDeduper.clear();
90
93
  this.$connected = false;
91
94
  }
92
95
 
@@ -127,4 +130,35 @@ export class NapCatWsClient extends NapCatBotBase {
127
130
  try { await this.$connect(); } catch { this.scheduleReconnect(); }
128
131
  }, interval);
129
132
  }
133
+
134
+ protected handleMeta(event: any): void {
135
+ if (event.meta_event_type === 'lifecycle' && event.sub_type === 'connect') {
136
+ this.logger.info(formatCompact({ bot: this.$id, lifecycle: 'connect', self_id: event.self_id }));
137
+ }
138
+ }
139
+
140
+ private initTypingIndicator(): void {
141
+ const tiConfig = this.$config.typingIndicator;
142
+ if (tiConfig && tiConfig.enabled !== false) {
143
+ enableTypingIndicator(this, {
144
+ enabled: tiConfig.enabled ?? true,
145
+ defaultEmoji: tiConfig.defaultEmoji || '128516',
146
+ autoRemove: true,
147
+ removeDelay: 5000,
148
+ privateConfig: tiConfig.privateConfig ? {
149
+ type: tiConfig.privateConfig.type || 'message',
150
+ message: tiConfig.privateConfig.message || '正在思考中...',
151
+ autoRemove: true,
152
+ removeDelay: 3000,
153
+ } : undefined,
154
+ groupConfig: tiConfig.groupConfig ? {
155
+ type: tiConfig.groupConfig.type || 'reaction',
156
+ emoji: tiConfig.groupConfig.emoji || '128516',
157
+ autoRemove: true,
158
+ removeDelay: 5000,
159
+ } : undefined,
160
+ });
161
+ this.logger.info(formatCompact({ bot: this.$id, typingIndicator: 'enabled' }));
162
+ }
163
+ }
130
164
  }
@@ -7,7 +7,8 @@ import type { IncomingMessage } from 'http';
7
7
  import { NapCatBotBase } from './bot-base.js';
8
8
  import type { NapCatWsServerConfig, ApiResponse } from './types.js';
9
9
  import type { NapCatAdapter } from './adapter.js';
10
- import type { Router } from '@zhin.js/http';
10
+ import type { Router } from '@zhin.js/host-router';
11
+ import { enableTypingIndicator } from './typing-indicator.js';
11
12
 
12
13
  export class NapCatWsServer extends NapCatBotBase {
13
14
  #wss?: WebSocketServer;
@@ -76,6 +77,7 @@ export class NapCatWsServer extends NapCatBotBase {
76
77
  if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
77
78
  for (const [, req] of this.pendingRequests) { clearTimeout(req.timeout); req.reject(new Error('Connection closed')); }
78
79
  this.pendingRequests.clear();
80
+ this.inboundDeduper.clear();
79
81
  this.$connected = false;
80
82
  }
81
83
 
@@ -118,6 +120,7 @@ export class NapCatWsServer extends NapCatBotBase {
118
120
  this.#clientMap.set(String(message.self_id), client);
119
121
  this.$connected = true;
120
122
  this.logger.info(formatCompact({ bot: this.$id, self_id: message.self_id }));
123
+ this.initTypingIndicator();
121
124
  return;
122
125
  }
123
126
  this.dispatchEvent(message);
@@ -132,4 +135,16 @@ export class NapCatWsServer extends NapCatBotBase {
132
135
  }
133
136
  }, interval);
134
137
  }
138
+
139
+ private initTypingIndicator(): void {
140
+ const tiConfig = (this.$config as any).typingIndicator;
141
+ if (tiConfig && tiConfig.enabled !== false) {
142
+ enableTypingIndicator(this, {
143
+ enabled: true,
144
+ defaultEmoji: tiConfig.defaultEmoji || '128516',
145
+ autoRemove: true,
146
+ removeDelay: 5000,
147
+ });
148
+ }
149
+ }
135
150
  }
package/src/index.ts CHANGED
@@ -3,9 +3,23 @@
3
3
  * 支持 OneBot11 标准 + go-cqhttp 扩展 + NapCat 独有 API
4
4
  * 连接方式:正向 WS / 反向 WS / HTTP
5
5
  */
6
- import { usePlugin, type Plugin, type Context, type IGroupManagement, createGroupManagementTools, type ToolFeature } from 'zhin.js';
6
+ import path from 'path';
7
+ import {
8
+ usePlugin,
9
+ type Plugin,
10
+ type Context,
11
+ type IGroupManagement,
12
+ createGroupManagementTools,
13
+ type ToolFeature,
14
+ registerAgentPromptContributor,
15
+ unregisterAgentPromptContributor,
16
+ } from 'zhin.js';
17
+ import type { Router } from '@zhin.js/host-router';
18
+ import { PageManager } from '@zhin.js/host-api';
7
19
  import { NapCatAdapter } from './adapter.js';
8
20
  import { createNapCatTools } from './tools.js';
21
+ import { createNapCatAgentPromptContributor } from './agent-prompt.js';
22
+ import { registerRoutes } from './routes.js';
9
23
 
10
24
  export * from './types.js';
11
25
  export { NapCatWsClient } from './bot-ws-client.js';
@@ -13,11 +27,17 @@ export { NapCatWsServer } from './bot-ws-server.js';
13
27
  export { NapCatHttpBot } from './bot-http.js';
14
28
  export { NapCatAdapter, type NapCatBot } from './adapter.js';
15
29
  export { NapCatBotBase } from './bot-base.js';
30
+ export {
31
+ NapCatTypingIndicatorManager,
32
+ enableTypingIndicator,
33
+ type NapCatTypingIndicatorConfig,
34
+ } from './typing-indicator.js';
16
35
 
17
36
  declare module 'zhin.js' {
18
37
  namespace Plugin {
19
38
  interface Contexts {
20
- router: import('@zhin.js/http').Router;
39
+ web: PageManager;
40
+ router: Router;
21
41
  }
22
42
  }
23
43
  interface Adapters {
@@ -28,19 +48,23 @@ declare module 'zhin.js' {
28
48
  const plugin = usePlugin();
29
49
  const { provide, useContext } = plugin;
30
50
 
51
+ // ── 适配器注册 ─────────────────────────────────────────────────────
31
52
  provide({
32
53
  name: 'napcat',
33
54
  description: 'NapCatQQ 适配器(OneBot11 + go-cqhttp + NapCat 扩展,正向/反向 WS + HTTP)',
34
55
  mounted: async (p: Plugin) => {
56
+ registerAgentPromptContributor(createNapCatAgentPromptContributor());
35
57
  const adapter = new NapCatAdapter(p);
36
58
  await adapter.start();
37
59
  return adapter;
38
60
  },
39
61
  dispose: async (adapter: NapCatAdapter) => {
62
+ unregisterAgentPromptContributor('napcat');
40
63
  await adapter.stop();
41
64
  },
42
65
  } as unknown as Context<'napcat'>);
43
66
 
67
+ // ── AI 工具注册 ────────────────────────────────────────────────────
44
68
  useContext('tool', 'napcat', (toolService: ToolFeature, napcat: NapCatAdapter) => {
45
69
  const disposers: (() => void)[] = [];
46
70
 
@@ -55,3 +79,18 @@ useContext('tool', 'napcat', (toolService: ToolFeature, napcat: NapCatAdapter) =
55
79
 
56
80
  return () => disposers.forEach(d => d());
57
81
  });
82
+
83
+ // ── Web 控制台入口 ─────────────────────────────────────────────────
84
+ useContext('web', (pageManager) => {
85
+ pageManager.addEntry({
86
+ id: 'napcat',
87
+ development: path.resolve(import.meta.dirname, '../client/index.tsx'),
88
+ production: path.resolve(import.meta.dirname, '../dist/index.js'),
89
+ meta: { name: 'NapCat' },
90
+ });
91
+ });
92
+
93
+ // ── HTTP 路由 ──────────────────────────────────────────────────────
94
+ useContext('router', 'napcat', async (router: Router, napcat: NapCatAdapter) => {
95
+ registerRoutes(router, napcat);
96
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * NapCat 入站消息治理:去重、自发过滤、消息归一化
3
+ */
4
+ import type { NapCatMessageEvent, MessageSegment } from './types.js';
5
+
6
+ const DEDUPE_TTL_MS = 120_000;
7
+
8
+ export class InboundMessageDeduper {
9
+ private readonly seen = new Map<string, number>();
10
+
11
+ shouldProcess(messageId: string): boolean {
12
+ const now = Date.now();
13
+ for (const [id, t] of this.seen) {
14
+ if (now - t > DEDUPE_TTL_MS) this.seen.delete(id);
15
+ }
16
+ if (this.seen.has(messageId)) return false;
17
+ this.seen.set(messageId, now);
18
+ return true;
19
+ }
20
+
21
+ clear(): void {
22
+ this.seen.clear();
23
+ }
24
+ }
25
+
26
+ /** 判断是否为 bot 自身发出的消息 */
27
+ export function isSelfMessage(event: NapCatMessageEvent): boolean {
28
+ if (event.post_type === 'message_sent') return true;
29
+ if (event.self_id != null && event.user_id != null) {
30
+ return Number(event.self_id) === Number(event.user_id);
31
+ }
32
+ return false;
33
+ }
34
+
35
+ /**
36
+ * 将 message 字段归一化为 MessageSegment[]。
37
+ * NapCat 通常返回数组,但某些配置下可能返回 CQ 字符串。
38
+ */
39
+ export function normalizeMessage(message: MessageSegment[] | string): MessageSegment[] {
40
+ if (Array.isArray(message)) return message;
41
+ if (typeof message === 'string') {
42
+ return [{ type: 'text', data: { text: message } }];
43
+ }
44
+ return [];
45
+ }
46
+
47
+ /**
48
+ * 生成用于 notice / request 事件的去重 key
49
+ */
50
+ export function resolveSideEventDedupeKey(event: any, prefix: string): string {
51
+ const parts = [prefix, event.time, event.notice_type || event.request_type];
52
+ if (event.group_id) parts.push(event.group_id);
53
+ if (event.user_id) parts.push(event.user_id);
54
+ if (event.message_id) parts.push(event.message_id);
55
+ if (event.flag) parts.push(event.flag);
56
+ return parts.join(':');
57
+ }
@@ -0,0 +1,33 @@
1
+ import type { MessageSegment, QuotedMessagePayload } from 'zhin.js';
2
+
3
+ export function parseOneBotGetMsgResponse(
4
+ messageId: string,
5
+ data: unknown,
6
+ ): QuotedMessagePayload {
7
+ const record =
8
+ data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
9
+ let content: QuotedMessagePayload['content'] = [];
10
+ if (Array.isArray(record.message)) {
11
+ content = record.message as MessageSegment[];
12
+ } else if (typeof record.raw_message === 'string' && record.raw_message) {
13
+ content = [{ type: 'text', data: { text: record.raw_message } }];
14
+ }
15
+
16
+ const senderRaw = record.sender;
17
+ let sender: QuotedMessagePayload['sender'];
18
+ if (senderRaw && typeof senderRaw === 'object') {
19
+ const s = senderRaw as Record<string, unknown>;
20
+ sender = {
21
+ id: String(s.user_id ?? ''),
22
+ name: String(s.nickname ?? s.card ?? ''),
23
+ };
24
+ }
25
+
26
+ return {
27
+ messageId,
28
+ sender,
29
+ content,
30
+ raw: typeof record.raw_message === 'string' ? record.raw_message : undefined,
31
+ time: typeof record.time === 'number' ? record.time : undefined,
32
+ };
33
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { Router } from '@zhin.js/host-router';
2
+ import type { NapCatAdapter } from './adapter.js';
3
+
4
+ export function registerRoutes(router: Router, napcat: NapCatAdapter): void {
5
+ router.get('/api/napcat/bots', async (ctx) => {
6
+ try {
7
+ const bots = Array.from(napcat.bots.values());
8
+ if (bots.length === 0) {
9
+ ctx.body = { success: true, data: [], message: '暂无 NapCat 机器人实例' };
10
+ return;
11
+ }
12
+ const result = await Promise.all(
13
+ bots.map(async (bot) => {
14
+ try {
15
+ const connection = (bot.$config as any).connection ?? 'ws';
16
+ const base: Record<string, unknown> = {
17
+ name: bot.$config.name,
18
+ connected: bot.$connected || false,
19
+ connection,
20
+ status: bot.$connected ? 'online' : 'offline',
21
+ lastActivity: new Date().toISOString(),
22
+ };
23
+ if (bot.$connected) {
24
+ try {
25
+ const friends = await bot.getFriendList();
26
+ const groups = await bot.getGroupList();
27
+ base.friendCount = Array.isArray(friends) ? friends.length : 0;
28
+ base.groupCount = Array.isArray(groups) ? groups.length : 0;
29
+ } catch {
30
+ base.friendCount = 0;
31
+ base.groupCount = 0;
32
+ }
33
+ } else {
34
+ base.friendCount = 0;
35
+ base.groupCount = 0;
36
+ }
37
+ return base;
38
+ } catch {
39
+ return {
40
+ name: bot.$config.name,
41
+ connected: false,
42
+ connection: 'unknown',
43
+ status: 'error',
44
+ friendCount: 0,
45
+ groupCount: 0,
46
+ };
47
+ }
48
+ }),
49
+ );
50
+ ctx.body = { success: true, data: result, timestamp: new Date().toISOString() };
51
+ } catch (error) {
52
+ ctx.status = 500;
53
+ ctx.body = {
54
+ success: false,
55
+ error: 'NAPCAT_API_ERROR',
56
+ message: '获取机器人数据失败',
57
+ details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined,
58
+ };
59
+ }
60
+ });
61
+ }
package/src/types.ts CHANGED
@@ -5,10 +5,23 @@
5
5
 
6
6
  // ── 配置 ─────────────────────────────────────────────────────────────
7
7
 
8
+ export interface TypingIndicatorConfig {
9
+ /** 是否启用(默认 true) */
10
+ enabled?: boolean;
11
+ /** 默认表情 ID */
12
+ defaultEmoji?: string;
13
+ /** 私聊配置 */
14
+ privateConfig?: { type?: 'message' | 'typing'; message?: string };
15
+ /** 群聊配置 */
16
+ groupConfig?: { type?: 'reaction'; emoji?: string };
17
+ }
18
+
8
19
  export interface NapCatConfigBase {
9
20
  context: 'napcat';
10
21
  name: string;
11
22
  access_token?: string;
23
+ /** Typing Indicator 配置 */
24
+ typingIndicator?: TypingIndicatorConfig;
12
25
  }
13
26
 
14
27
  /** 正向 WebSocket */