@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.
- package/CHANGELOG.md +36 -13
- package/README.md +121 -0
- package/client/NapCatManagement.tsx +113 -0
- package/client/index.tsx +11 -0
- package/client/tsconfig.json +7 -0
- package/client/utils/api.ts +30 -0
- package/dist/index.js +27 -0
- package/lib/adapter.d.ts +3 -0
- package/lib/adapter.d.ts.map +1 -1
- package/lib/adapter.js +5 -2
- package/lib/adapter.js.map +1 -1
- package/lib/agent-prompt.d.ts +3 -0
- package/lib/agent-prompt.d.ts.map +1 -0
- package/lib/agent-prompt.js +107 -0
- package/lib/agent-prompt.js.map +1 -0
- package/lib/bot-base.d.ts +6 -1
- package/lib/bot-base.d.ts.map +1 -1
- package/lib/bot-base.js +35 -1
- package/lib/bot-base.js.map +1 -1
- package/lib/bot-http.d.ts +2 -1
- package/lib/bot-http.d.ts.map +1 -1
- package/lib/bot-http.js +23 -1
- package/lib/bot-http.js.map +1 -1
- package/lib/bot-ws-client.d.ts +2 -0
- package/lib/bot-ws-client.d.ts.map +1 -1
- package/lib/bot-ws-client.js +32 -0
- package/lib/bot-ws-client.js.map +1 -1
- package/lib/bot-ws-server.d.ts +2 -1
- package/lib/bot-ws-server.d.ts.map +1 -1
- package/lib/bot-ws-server.js +14 -0
- package/lib/bot-ws-server.js.map +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +22 -1
- package/lib/index.js.map +1 -1
- package/lib/napcat-inbound.d.ts +21 -0
- package/lib/napcat-inbound.d.ts.map +1 -0
- package/lib/napcat-inbound.js +55 -0
- package/lib/napcat-inbound.js.map +1 -0
- package/lib/onebot-get-msg.d.ts +3 -0
- package/lib/onebot-get-msg.d.ts.map +1 -0
- package/lib/onebot-get-msg.js +27 -0
- package/lib/onebot-get-msg.js.map +1 -0
- package/lib/routes.d.ts +4 -0
- package/lib/routes.d.ts.map +1 -0
- package/lib/routes.js +61 -0
- package/lib/routes.js.map +1 -0
- package/lib/types.d.ts +18 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/typing-indicator.d.ts +45 -0
- package/lib/typing-indicator.d.ts.map +1 -0
- package/lib/typing-indicator.js +132 -0
- package/lib/typing-indicator.js.map +1 -0
- package/package.json +22 -6
- package/plugin.yml +1 -1
- package/src/adapter.ts +3 -3
- package/src/agent-prompt.ts +115 -0
- package/src/bot-base.ts +36 -1
- package/src/bot-http.ts +19 -1
- package/src/bot-ws-client.ts +34 -0
- package/src/bot-ws-server.ts +16 -1
- package/src/index.ts +41 -2
- package/src/napcat-inbound.ts +57 -0
- package/src/onebot-get-msg.ts +33 -0
- package/src/routes.ts +61 -0
- package/src/types.ts +13 -0
- 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:
|
|
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/
|
|
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
|
}
|
package/src/bot-ws-client.ts
CHANGED
|
@@ -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
|
}
|
package/src/bot-ws-server.ts
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
-
|
|
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 */
|