@izhimu/qq 0.1.1
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 +21 -0
- package/README.md +388 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +19 -0
- package/dist/src/adapters/message.d.ts +8 -0
- package/dist/src/adapters/message.js +152 -0
- package/dist/src/channel.d.ts +7 -0
- package/dist/src/channel.js +312 -0
- package/dist/src/core/config.d.ts +22 -0
- package/dist/src/core/config.js +32 -0
- package/dist/src/core/connection.d.ts +65 -0
- package/dist/src/core/connection.js +328 -0
- package/dist/src/core/dispatch.d.ts +54 -0
- package/dist/src/core/dispatch.js +285 -0
- package/dist/src/core/request.d.ts +26 -0
- package/dist/src/core/request.js +115 -0
- package/dist/src/core/runtime.d.ts +16 -0
- package/dist/src/core/runtime.js +48 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +98 -0
- package/dist/src/types/index.d.ts +261 -0
- package/dist/src/types/index.js +5 -0
- package/dist/src/utils/cqcode.d.ts +33 -0
- package/dist/src/utils/cqcode.js +102 -0
- package/dist/src/utils/index.d.ts +51 -0
- package/dist/src/utils/index.js +250 -0
- package/dist/src/utils/log.d.ts +6 -0
- package/dist/src/utils/log.js +23 -0
- package/dist/src/utils/markdown.d.ts +29 -0
- package/dist/src/utils/markdown.js +137 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +61 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Connection Manager for NapCat
|
|
3
|
+
* Handles per-account WebSocket connections with auto-reconnect and heartbeat
|
|
4
|
+
*/
|
|
5
|
+
import WebSocket from 'ws';
|
|
6
|
+
import EventEmitter from 'events';
|
|
7
|
+
import { Logger as log, generateEchoId, calculateBackoff, getCloseCodeMessage, } from '../utils/index.js';
|
|
8
|
+
const MAX_RECONNECT_ATTEMPTS = -1;
|
|
9
|
+
const REQUEST_TIMEOUT = 30000; // 30 seconds
|
|
10
|
+
/**
|
|
11
|
+
* Connection Manager for a single NapCat account
|
|
12
|
+
*/
|
|
13
|
+
export class ConnectionManager extends EventEmitter {
|
|
14
|
+
config;
|
|
15
|
+
ws = null;
|
|
16
|
+
state = 'disconnected';
|
|
17
|
+
// Heartbeat - active ping + OneBot 11 meta_event based
|
|
18
|
+
lastHeartbeatTime = 0;
|
|
19
|
+
// Connection stats
|
|
20
|
+
totalReconnectAttempts = 0;
|
|
21
|
+
// Reconnection
|
|
22
|
+
reconnectTimer;
|
|
23
|
+
reconnectAttempts = 0;
|
|
24
|
+
shouldReconnect = true;
|
|
25
|
+
// Pending requests
|
|
26
|
+
pendingRequests = new Map();
|
|
27
|
+
// Health status
|
|
28
|
+
healthStatus = {
|
|
29
|
+
healthy: false,
|
|
30
|
+
lastHeartbeatAt: 0,
|
|
31
|
+
consecutiveFailures: 0,
|
|
32
|
+
};
|
|
33
|
+
constructor(config) {
|
|
34
|
+
super();
|
|
35
|
+
this.config = config;
|
|
36
|
+
}
|
|
37
|
+
// ==========================================================================
|
|
38
|
+
// Connection Lifecycle
|
|
39
|
+
// ==========================================================================
|
|
40
|
+
/**
|
|
41
|
+
* Start the connection
|
|
42
|
+
*/
|
|
43
|
+
async start() {
|
|
44
|
+
if (this.state === 'connected' || this.state === 'connecting') {
|
|
45
|
+
log.debug('connection', `Already ${this.state}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.shouldReconnect = true;
|
|
49
|
+
this.reconnectAttempts = 0;
|
|
50
|
+
await this.connect();
|
|
51
|
+
log.info('connection', `Started connection`);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Stop the connection
|
|
55
|
+
*/
|
|
56
|
+
async stop() {
|
|
57
|
+
this.shouldReconnect = false;
|
|
58
|
+
this.clearReconnectTimer();
|
|
59
|
+
await this.close('Stopping connection');
|
|
60
|
+
this.setState('disconnected');
|
|
61
|
+
log.info('connection', `Stopped connection`);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Establish WebSocket connection
|
|
65
|
+
*/
|
|
66
|
+
async connect() {
|
|
67
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.setState('connecting');
|
|
71
|
+
try {
|
|
72
|
+
// Build WebSocket URL with access_token query parameter (NapCat OneBot 11 standard)
|
|
73
|
+
let wsUrl = this.config.wsUrl;
|
|
74
|
+
if (this.config.accessToken) {
|
|
75
|
+
const url = new URL(wsUrl);
|
|
76
|
+
url.searchParams.set('access_token', this.config.accessToken);
|
|
77
|
+
wsUrl = url.toString();
|
|
78
|
+
}
|
|
79
|
+
log.info('connection', `Connecting to ${wsUrl}`);
|
|
80
|
+
this.ws = new WebSocket(wsUrl);
|
|
81
|
+
this.ws.on('open', this.handleOpen.bind(this));
|
|
82
|
+
this.ws.on('message', this.handleMessage.bind(this));
|
|
83
|
+
this.ws.on('error', this.handleError.bind(this));
|
|
84
|
+
this.ws.on('close', this.handleClose.bind(this));
|
|
85
|
+
// Wait for connection to be established or failed
|
|
86
|
+
await new Promise((resolve, reject) => {
|
|
87
|
+
const timeout = setTimeout(() => {
|
|
88
|
+
reject(new Error('Connection timeout'));
|
|
89
|
+
}, 30000);
|
|
90
|
+
this.once('connected', () => {
|
|
91
|
+
clearTimeout(timeout);
|
|
92
|
+
resolve();
|
|
93
|
+
});
|
|
94
|
+
this.once('failed', (error) => {
|
|
95
|
+
clearTimeout(timeout);
|
|
96
|
+
reject(error);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
log.error('connection', `Connection failed:`, error);
|
|
102
|
+
this.handleConnectionFailed(error instanceof Error ? error : new Error(String(error)));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Close WebSocket connection
|
|
107
|
+
*/
|
|
108
|
+
async close(reason) {
|
|
109
|
+
if (this.ws) {
|
|
110
|
+
log.info('connection', `Closing connection: ${reason}`);
|
|
111
|
+
// Clear event listeners to prevent further processing
|
|
112
|
+
this.ws.removeAllListeners();
|
|
113
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
114
|
+
this.ws.close(1000, reason);
|
|
115
|
+
}
|
|
116
|
+
this.ws = null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// ==========================================================================
|
|
120
|
+
// WebSocket Event Handlers
|
|
121
|
+
// ==========================================================================
|
|
122
|
+
handleOpen() {
|
|
123
|
+
log.info('connection', `Connected to NapCat`);
|
|
124
|
+
this.setState('connected');
|
|
125
|
+
this.totalReconnectAttempts += this.reconnectAttempts;
|
|
126
|
+
this.reconnectAttempts = 0;
|
|
127
|
+
this.emit('connected');
|
|
128
|
+
}
|
|
129
|
+
handleMessage(data) {
|
|
130
|
+
try {
|
|
131
|
+
const message = JSON.parse(data.toString());
|
|
132
|
+
// Handle response to a request
|
|
133
|
+
if ('echo' in message && message.echo) {
|
|
134
|
+
this.handleResponse(message);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Handle meta_event (heartbeat/lifecycle)
|
|
138
|
+
if ('post_type' in message && message.post_type === 'meta_event') {
|
|
139
|
+
this.handleMetaEvent(message);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Handle event
|
|
143
|
+
if ('post_type' in message) {
|
|
144
|
+
this.emit('event', message);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
log.debug('connection', `Received unsolicited response:`, message);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
log.error('connection', `Failed to parse message:`, error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Handle OneBot 11 meta_event (lifecycle and heartbeat)
|
|
155
|
+
*/
|
|
156
|
+
handleMetaEvent(event) {
|
|
157
|
+
if (event.meta_event_type === 'heartbeat') {
|
|
158
|
+
// NapCat sent us a heartbeat - update health status
|
|
159
|
+
this.lastHeartbeatTime = Date.now();
|
|
160
|
+
this.healthStatus = {
|
|
161
|
+
healthy: true,
|
|
162
|
+
lastHeartbeatAt: this.lastHeartbeatTime,
|
|
163
|
+
consecutiveFailures: 0,
|
|
164
|
+
};
|
|
165
|
+
log.debug('connection', `Received heartbeat`);
|
|
166
|
+
this.emit('heartbeat', this.healthStatus);
|
|
167
|
+
}
|
|
168
|
+
else if (event.meta_event_type === 'lifecycle') {
|
|
169
|
+
log.info('connection', `Lifecycle event: ${event.sub_type}`);
|
|
170
|
+
this.emit('lifecycle', event);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
handleError(error) {
|
|
174
|
+
log.error('connection', `WebSocket error:`, error.message);
|
|
175
|
+
}
|
|
176
|
+
handleClose(code, reason) {
|
|
177
|
+
const reasonStr = reason.toString() || getCloseCodeMessage(code);
|
|
178
|
+
log.warn('connection', `Connection closed: ${code} - ${reasonStr}`);
|
|
179
|
+
if (this.shouldReconnect && !this.isNormalClosure(code)) {
|
|
180
|
+
this.scheduleReconnect();
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
this.setState('disconnected');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
handleConnectionFailed(error) {
|
|
187
|
+
this.setState('failed', error.message);
|
|
188
|
+
this.emit('failed', error);
|
|
189
|
+
if (this.shouldReconnect) {
|
|
190
|
+
this.scheduleReconnect();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
handleResponse(response) {
|
|
194
|
+
const { echo } = response;
|
|
195
|
+
if (!echo) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const pending = this.pendingRequests.get(echo);
|
|
199
|
+
if (!pending) {
|
|
200
|
+
log.debug('connection', `Received response for unknown request: ${echo}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.pendingRequests.delete(echo);
|
|
204
|
+
clearTimeout(pending.timeout);
|
|
205
|
+
if (response.status === 'ok') {
|
|
206
|
+
pending.resolve(response);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
pending.reject(new Error(response.msg || 'Request failed'));
|
|
210
|
+
}
|
|
211
|
+
log.debug('connection', `Received response for echo: ${echo}`);
|
|
212
|
+
}
|
|
213
|
+
isNormalClosure(code) {
|
|
214
|
+
return code === 1000 || code === 1001;
|
|
215
|
+
}
|
|
216
|
+
// ==========================================================================
|
|
217
|
+
// Reconnection Logic
|
|
218
|
+
// ==========================================================================
|
|
219
|
+
scheduleReconnect() {
|
|
220
|
+
if (!this.shouldReconnect) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (MAX_RECONNECT_ATTEMPTS != -1 && this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
224
|
+
log.error('connection', `Max reconnect attempts reached`);
|
|
225
|
+
this.setState('failed', 'Max reconnect attempts reached');
|
|
226
|
+
this.emit('max-reconnect-attempts-reached');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const delayMs = calculateBackoff(this.reconnectAttempts);
|
|
230
|
+
log.info('connection', `Scheduling reconnect in ${delayMs}ms (attempt ${this.reconnectAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`);
|
|
231
|
+
this.clearReconnectTimer();
|
|
232
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
233
|
+
this.reconnectAttempts++;
|
|
234
|
+
try {
|
|
235
|
+
await this.connect();
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
log.error('connection', `Reconnect failed:`, error);
|
|
239
|
+
}
|
|
240
|
+
}, delayMs);
|
|
241
|
+
}
|
|
242
|
+
clearReconnectTimer() {
|
|
243
|
+
if (this.reconnectTimer) {
|
|
244
|
+
clearTimeout(this.reconnectTimer);
|
|
245
|
+
this.reconnectTimer = undefined;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// ==========================================================================
|
|
249
|
+
// Request/Response Handling
|
|
250
|
+
// ==========================================================================
|
|
251
|
+
/**
|
|
252
|
+
* Send a request and wait for response
|
|
253
|
+
*/
|
|
254
|
+
async sendRequest(action, params) {
|
|
255
|
+
if (!this.isConnected()) {
|
|
256
|
+
return failResp();
|
|
257
|
+
}
|
|
258
|
+
const echo = generateEchoId();
|
|
259
|
+
return new Promise((resolve, reject) => {
|
|
260
|
+
// Set up timeout
|
|
261
|
+
const timeout = setTimeout(() => {
|
|
262
|
+
this.pendingRequests.delete(echo);
|
|
263
|
+
reject(new Error(`Request timeout: ${action}`));
|
|
264
|
+
}, REQUEST_TIMEOUT);
|
|
265
|
+
// Store pending request
|
|
266
|
+
this.pendingRequests.set(echo, {
|
|
267
|
+
resolve: resolve,
|
|
268
|
+
reject,
|
|
269
|
+
timeout,
|
|
270
|
+
});
|
|
271
|
+
// Send request
|
|
272
|
+
const request = {
|
|
273
|
+
action,
|
|
274
|
+
params,
|
|
275
|
+
echo,
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
this.ws?.send(JSON.stringify(request));
|
|
279
|
+
log.debug('connection', `Sent request: ${action} (echo: ${echo})`);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
this.pendingRequests.delete(echo);
|
|
283
|
+
clearTimeout(timeout);
|
|
284
|
+
reject(error);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
// ==========================================================================
|
|
289
|
+
// State Management
|
|
290
|
+
// ==========================================================================
|
|
291
|
+
setState(state, error) {
|
|
292
|
+
const oldState = this.state;
|
|
293
|
+
this.state = state;
|
|
294
|
+
log.info('connection', `State changed: ${oldState} -> ${state}`);
|
|
295
|
+
if (state === 'connected') {
|
|
296
|
+
this.lastHeartbeatTime = Date.now();
|
|
297
|
+
}
|
|
298
|
+
this.emit('state-changed', { ...this.getStatus(), error });
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Get current connection status
|
|
302
|
+
*/
|
|
303
|
+
getStatus() {
|
|
304
|
+
return {
|
|
305
|
+
state: this.state,
|
|
306
|
+
lastConnected: this.lastHeartbeatTime || undefined,
|
|
307
|
+
lastAttempted: this.reconnectAttempts > 0 ? Date.now() : undefined,
|
|
308
|
+
error: this.state === 'failed' ? 'Connection failed' : undefined,
|
|
309
|
+
reconnectAttempts: this.reconnectAttempts > 0 ? this.reconnectAttempts : undefined,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// ==========================================================================
|
|
313
|
+
// Public API
|
|
314
|
+
// ==========================================================================
|
|
315
|
+
/**
|
|
316
|
+
* Check if connected
|
|
317
|
+
*/
|
|
318
|
+
isConnected() {
|
|
319
|
+
return this.state === 'connected' && this.ws?.readyState === WebSocket.OPEN;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
export async function failResp(msg = '') {
|
|
323
|
+
return Promise.resolve({
|
|
324
|
+
status: "failed",
|
|
325
|
+
retcode: -1,
|
|
326
|
+
msg
|
|
327
|
+
});
|
|
328
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Dispatch Module
|
|
3
|
+
* Handles routing and dispatching incoming messages to the AI
|
|
4
|
+
*/
|
|
5
|
+
import type { DispatchMessageParams } from '../types/index.js';
|
|
6
|
+
/**
|
|
7
|
+
* Dispatch an incoming message to the AI for processing
|
|
8
|
+
*/
|
|
9
|
+
export declare function dispatchMessage(params: DispatchMessageParams): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Handle group message event
|
|
12
|
+
*/
|
|
13
|
+
export declare function handleGroupMessage(event: {
|
|
14
|
+
time: number;
|
|
15
|
+
self_id: number;
|
|
16
|
+
message_id: number;
|
|
17
|
+
group_id: number;
|
|
18
|
+
user_id: number;
|
|
19
|
+
message: Array<{
|
|
20
|
+
type: string;
|
|
21
|
+
data: Record<string, unknown>;
|
|
22
|
+
}>;
|
|
23
|
+
raw_message: string;
|
|
24
|
+
sender?: {
|
|
25
|
+
nickname?: string;
|
|
26
|
+
card?: string;
|
|
27
|
+
};
|
|
28
|
+
}): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Handle private message event
|
|
31
|
+
*/
|
|
32
|
+
export declare function handlePrivateMessage(event: {
|
|
33
|
+
time: number;
|
|
34
|
+
self_id: number;
|
|
35
|
+
message_id: number;
|
|
36
|
+
user_id: number;
|
|
37
|
+
message: Array<{
|
|
38
|
+
type: string;
|
|
39
|
+
data: Record<string, unknown>;
|
|
40
|
+
}>;
|
|
41
|
+
raw_message: string;
|
|
42
|
+
sender?: {
|
|
43
|
+
nickname?: string;
|
|
44
|
+
};
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
export declare function handlePokeEvent(event: {
|
|
47
|
+
user_id: number;
|
|
48
|
+
target_id: number;
|
|
49
|
+
group_id?: number;
|
|
50
|
+
raw_info?: Array<{
|
|
51
|
+
type: string;
|
|
52
|
+
txt?: string;
|
|
53
|
+
}>;
|
|
54
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Dispatch Module
|
|
3
|
+
* Handles routing and dispatching incoming messages to the AI
|
|
4
|
+
*/
|
|
5
|
+
import { getRuntime, getContext } from './runtime.js';
|
|
6
|
+
import { getMsg, getFile, sendMsg, setInputStatus } from './request.js';
|
|
7
|
+
import { napCatToOpenClawMessage } from '../adapters/message.js';
|
|
8
|
+
import { Logger as log, markdownToText } from '../utils/index.js';
|
|
9
|
+
import { CHANNEL_ID } from "./config.js";
|
|
10
|
+
/**
|
|
11
|
+
* Convert OpenClaw message content array to plain text
|
|
12
|
+
* For images, includes the URL so AI models can access them
|
|
13
|
+
* For replies, includes quoted message content if available
|
|
14
|
+
*/
|
|
15
|
+
async function contentToPlainText(content) {
|
|
16
|
+
return content
|
|
17
|
+
.filter(c => c.type !== 'reply' && c.type !== 'image' && c.type !== 'audio' && c.type !== 'file')
|
|
18
|
+
.map((c) => {
|
|
19
|
+
switch (c.type) {
|
|
20
|
+
case 'text':
|
|
21
|
+
return c.text;
|
|
22
|
+
case 'at':
|
|
23
|
+
return c.isAll ? '@全体成员' : `@${c.userId}`;
|
|
24
|
+
case 'json':
|
|
25
|
+
return `[JSON]\n\`\`\`json\n${c.data}\n\`\`\``;
|
|
26
|
+
default:
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
}).join('');
|
|
30
|
+
}
|
|
31
|
+
async function contextToMedia(content) {
|
|
32
|
+
const hasMedia = content.some(c => c.type === 'image' || c.type === 'audio' || c.type === 'file');
|
|
33
|
+
if (!hasMedia) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const image = content.find(c => c.type === 'image');
|
|
37
|
+
if (image) {
|
|
38
|
+
return {
|
|
39
|
+
type: 'image/jpeg',
|
|
40
|
+
path: image.url,
|
|
41
|
+
url: image.url,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const audio = content.find(c => c.type === 'audio');
|
|
45
|
+
if (audio) {
|
|
46
|
+
return {
|
|
47
|
+
type: 'audio/amr',
|
|
48
|
+
path: audio.path,
|
|
49
|
+
url: audio.url,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const file = content.find(c => c.type === 'file');
|
|
53
|
+
if (file) {
|
|
54
|
+
const fileInfo = await getFile({ file_id: file.fileId });
|
|
55
|
+
if (fileInfo.data?.file == undefined) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
type: 'application/octet-stream',
|
|
60
|
+
path: fileInfo.data?.file,
|
|
61
|
+
url: fileInfo.data?.url,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
async function contextToReply(content) {
|
|
67
|
+
const hasReply = content.some(c => c.type === 'reply');
|
|
68
|
+
if (!hasReply) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const reply = content.find(c => c.type === 'reply');
|
|
72
|
+
if (!reply) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const response = await getMsg({
|
|
76
|
+
message_id: Number(reply.messageId),
|
|
77
|
+
});
|
|
78
|
+
if (response.data?.message == undefined) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const replyMessage = await napCatToOpenClawMessage(response.data?.message);
|
|
82
|
+
const text = await contentToPlainText(replyMessage);
|
|
83
|
+
return {
|
|
84
|
+
id: reply.messageId,
|
|
85
|
+
content: text,
|
|
86
|
+
sender: String(response.data?.sender.user_id)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Dispatch an incoming message to the AI for processing
|
|
91
|
+
*/
|
|
92
|
+
export async function dispatchMessage(params) {
|
|
93
|
+
const { chatType, chatId, senderId, senderName, messageId, content, media, reply, timestamp } = params;
|
|
94
|
+
const runtime = getRuntime();
|
|
95
|
+
if (!runtime) {
|
|
96
|
+
log.warn('dispatch', `Plugin runtime not available`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const context = getContext();
|
|
100
|
+
if (!context) {
|
|
101
|
+
log.warn('dispatch', `No gateway context`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const isGroup = chatType === 'group';
|
|
105
|
+
const peerId = isGroup ? `group:${chatId}` : senderId;
|
|
106
|
+
if (!isGroup) {
|
|
107
|
+
// 输入状态
|
|
108
|
+
await setInputStatus({
|
|
109
|
+
user_id: senderId,
|
|
110
|
+
event_type: 1
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
114
|
+
cfg: context.cfg,
|
|
115
|
+
channel: CHANNEL_ID,
|
|
116
|
+
peer: {
|
|
117
|
+
kind: isGroup ? 'group' : 'dm',
|
|
118
|
+
id: peerId,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const envelopeOptions = runtime.channel.reply.resolveEnvelopeFormatOptions(context.cfg);
|
|
122
|
+
const body = runtime.channel.reply.formatInboundEnvelope({
|
|
123
|
+
channel: CHANNEL_ID,
|
|
124
|
+
from: senderName || senderId,
|
|
125
|
+
body: content,
|
|
126
|
+
timestamp,
|
|
127
|
+
chatType: isGroup ? 'group' : 'direct',
|
|
128
|
+
sender: {
|
|
129
|
+
id: senderId,
|
|
130
|
+
name: senderName,
|
|
131
|
+
},
|
|
132
|
+
envelope: envelopeOptions,
|
|
133
|
+
});
|
|
134
|
+
const fromAddress = isGroup ? `qq:group:${chatId}` : `qq:private:${senderId}`;
|
|
135
|
+
const toAddress = `qq:${route.accountId}`;
|
|
136
|
+
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
137
|
+
Body: body,
|
|
138
|
+
RawBody: content,
|
|
139
|
+
CommandBody: content,
|
|
140
|
+
From: fromAddress,
|
|
141
|
+
To: toAddress,
|
|
142
|
+
SessionKey: route.sessionKey,
|
|
143
|
+
AccountId: route.accountId,
|
|
144
|
+
ChatType: isGroup ? 'group' : 'direct',
|
|
145
|
+
SenderId: senderId,
|
|
146
|
+
SenderName: senderName,
|
|
147
|
+
Provider: CHANNEL_ID,
|
|
148
|
+
Surface: CHANNEL_ID,
|
|
149
|
+
MessageSid: messageId,
|
|
150
|
+
Timestamp: timestamp,
|
|
151
|
+
ReplyToId: reply?.id,
|
|
152
|
+
ReplyToBody: reply?.content,
|
|
153
|
+
ReplyToSender: reply?.sender,
|
|
154
|
+
ReplyToIsQuote: !!reply,
|
|
155
|
+
MediaType: media?.type,
|
|
156
|
+
MediaPath: media?.path,
|
|
157
|
+
MediaUrl: media?.url,
|
|
158
|
+
OriginatingChannel: CHANNEL_ID,
|
|
159
|
+
OriginatingTo: toAddress,
|
|
160
|
+
});
|
|
161
|
+
log.info('dispatch', `Dispatching to agent ${route.agentId}, session: ${route.sessionKey}`);
|
|
162
|
+
const sendReply = async (text) => {
|
|
163
|
+
const messageSegments = [{ type: 'text', data: { text: markdownToText(text) } }];
|
|
164
|
+
try {
|
|
165
|
+
await sendMsg({
|
|
166
|
+
message_type: isGroup ? 'group' : 'private',
|
|
167
|
+
group_id: isGroup ? chatId : undefined,
|
|
168
|
+
user_id: !isGroup ? chatId : undefined,
|
|
169
|
+
message: messageSegments,
|
|
170
|
+
});
|
|
171
|
+
log.info('dispatch', `Sent reply: ${text.slice(0, 100)}`);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
log.error('dispatch', `Send failed: ${error}`);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(context.cfg, route.agentId);
|
|
178
|
+
log.info('dispatch', `Messages config: ${JSON.stringify(messagesConfig)}`);
|
|
179
|
+
let hasResponse = false;
|
|
180
|
+
try {
|
|
181
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
182
|
+
ctx: ctxPayload,
|
|
183
|
+
cfg: context.cfg,
|
|
184
|
+
dispatcherOptions: {
|
|
185
|
+
humanDelay: {
|
|
186
|
+
mode: "off"
|
|
187
|
+
},
|
|
188
|
+
responsePrefix: messagesConfig.responsePrefix,
|
|
189
|
+
deliver: async (payload, info) => {
|
|
190
|
+
hasResponse = true;
|
|
191
|
+
log.info('dispatch', `deliver(${info.kind}): ${JSON.stringify(payload)}`);
|
|
192
|
+
if (payload.text) {
|
|
193
|
+
await sendReply(payload.text);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
onError: async (err) => {
|
|
197
|
+
hasResponse = true;
|
|
198
|
+
log.error('dispatch', `Dispatch error: ${err}`);
|
|
199
|
+
await sendReply(`[错误] ${String(err)}`);
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
replyOptions: {},
|
|
203
|
+
});
|
|
204
|
+
log.info('dispatch', `Dispatch completed, hasResponse: ${hasResponse}`);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
log.error('dispatch', `Message processing failed: ${error}`);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
if (!isGroup) {
|
|
211
|
+
// 输入状态
|
|
212
|
+
await setInputStatus({
|
|
213
|
+
user_id: senderId,
|
|
214
|
+
event_type: 2
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Handle group message event
|
|
221
|
+
*/
|
|
222
|
+
export async function handleGroupMessage(event) {
|
|
223
|
+
const content = await napCatToOpenClawMessage(event.message);
|
|
224
|
+
const plainText = await contentToPlainText(content);
|
|
225
|
+
const media = await contextToMedia(content);
|
|
226
|
+
const reply = await contextToReply(content);
|
|
227
|
+
log.info('dispatch', `Group message from ${event.sender?.nickname || event.sender?.card || event.user_id}: ${plainText}, media: ${media != undefined}, reply: ${reply != undefined}`);
|
|
228
|
+
await dispatchMessage({
|
|
229
|
+
chatType: 'group',
|
|
230
|
+
chatId: String(event.group_id),
|
|
231
|
+
senderId: String(event.user_id),
|
|
232
|
+
senderName: event.sender?.nickname || event.sender?.card,
|
|
233
|
+
messageId: String(event.message_id),
|
|
234
|
+
content: plainText,
|
|
235
|
+
media,
|
|
236
|
+
reply,
|
|
237
|
+
timestamp: event.time * 1000,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Handle private message event
|
|
242
|
+
*/
|
|
243
|
+
export async function handlePrivateMessage(event) {
|
|
244
|
+
const content = await napCatToOpenClawMessage(event.message);
|
|
245
|
+
const plainText = await contentToPlainText(content);
|
|
246
|
+
const media = await contextToMedia(content);
|
|
247
|
+
const reply = await contextToReply(content);
|
|
248
|
+
log.info('dispatch', `Private message from ${event.sender?.nickname || event.user_id}: ${plainText}, media: ${media != undefined}, reply: ${reply != undefined}`);
|
|
249
|
+
await dispatchMessage({
|
|
250
|
+
chatType: 'direct',
|
|
251
|
+
chatId: String(event.user_id),
|
|
252
|
+
senderId: String(event.user_id),
|
|
253
|
+
senderName: event.sender?.nickname,
|
|
254
|
+
messageId: String(event.message_id),
|
|
255
|
+
content: plainText,
|
|
256
|
+
media,
|
|
257
|
+
reply,
|
|
258
|
+
timestamp: event.time * 1000,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Handle poke event
|
|
263
|
+
*/
|
|
264
|
+
function extractPokeActionText(rawInfo) {
|
|
265
|
+
if (!rawInfo)
|
|
266
|
+
return '戳了戳';
|
|
267
|
+
const actionItem = rawInfo.find(item => item.type === 'nor' && item.txt);
|
|
268
|
+
return actionItem?.txt || '戳了戳';
|
|
269
|
+
}
|
|
270
|
+
export async function handlePokeEvent(event) {
|
|
271
|
+
const actionText = extractPokeActionText(event.raw_info);
|
|
272
|
+
log.info('dispatch', `Poke from ${event.user_id}: ${actionText}`);
|
|
273
|
+
const pokeMessage = actionText || '戳了戳';
|
|
274
|
+
const chatType = event.group_id ? 'group' : 'direct';
|
|
275
|
+
const chatId = String(event.group_id || event.user_id);
|
|
276
|
+
await dispatchMessage({
|
|
277
|
+
chatType,
|
|
278
|
+
chatId,
|
|
279
|
+
senderId: String(event.user_id),
|
|
280
|
+
senderName: String(event.user_id),
|
|
281
|
+
messageId: `poke_${event.user_id}_${Date.now()}`,
|
|
282
|
+
content: `[动作] ${pokeMessage}`,
|
|
283
|
+
timestamp: Date.now(),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { GetFileReq, GetFileResp, GetMsgReq, GetMsgResp, NapCatResp, SendMsgReq, SendMsgResp, SetInputStatusReq } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* 事件监听
|
|
4
|
+
* @param event
|
|
5
|
+
*/
|
|
6
|
+
export declare function eventListener(event: any): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* 发送消息
|
|
9
|
+
* @param params
|
|
10
|
+
*/
|
|
11
|
+
export declare function sendMsg(params: SendMsgReq): Promise<NapCatResp<SendMsgResp>>;
|
|
12
|
+
/**
|
|
13
|
+
* 获取消息
|
|
14
|
+
* @param params
|
|
15
|
+
*/
|
|
16
|
+
export declare function getMsg(params: GetMsgReq): Promise<NapCatResp<GetMsgResp>>;
|
|
17
|
+
/**
|
|
18
|
+
* 获取文件
|
|
19
|
+
* @param params
|
|
20
|
+
*/
|
|
21
|
+
export declare function getFile(params: GetFileReq): Promise<NapCatResp<GetFileResp>>;
|
|
22
|
+
/**
|
|
23
|
+
* 设置输入状态
|
|
24
|
+
* @param params
|
|
25
|
+
*/
|
|
26
|
+
export declare function setInputStatus(params: SetInputStatusReq): Promise<NapCatResp<void>>;
|