@hyl_aa/openclaw-napcat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/README_EN.md +129 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +49 -0
- package/src/accounts.ts +101 -0
- package/src/api.ts +134 -0
- package/src/channel.ts +366 -0
- package/src/config-schema.ts +72 -0
- package/src/monitor.ts +565 -0
- package/src/probe.ts +37 -0
- package/src/runtime.ts +6 -0
- package/src/send.ts +95 -0
- package/src/tools.ts +635 -0
- package/src/types.ts +104 -0
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { writeFile, unlink } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
8
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
9
|
+
import {
|
|
10
|
+
createTypingCallbacks,
|
|
11
|
+
createScopedPairingAccess,
|
|
12
|
+
createReplyPrefixOptions,
|
|
13
|
+
issuePairingChallenge,
|
|
14
|
+
resolveDirectDmAuthorizationOutcome,
|
|
15
|
+
resolveSenderCommandAuthorizationWithRuntime,
|
|
16
|
+
resolveOutboundMediaUrls,
|
|
17
|
+
resolveDefaultGroupPolicy,
|
|
18
|
+
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
|
19
|
+
waitUntilAbort,
|
|
20
|
+
} from "openclaw/plugin-sdk";
|
|
21
|
+
import type { ResolvedNapCatAccount } from "./types.js";
|
|
22
|
+
import type { OneBotMessageEvent, OneBotSegment } from "./types.js";
|
|
23
|
+
import { sendGroupMsg, sendPrivateMsg, textSegment, replySegment } from "./api.js";
|
|
24
|
+
import { getNapCatRuntime } from "./runtime.js";
|
|
25
|
+
|
|
26
|
+
export type NapCatRuntimeEnv = {
|
|
27
|
+
log?: (message: string) => void;
|
|
28
|
+
error?: (message: string) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type NapCatMonitorOptions = {
|
|
32
|
+
account: ResolvedNapCatAccount;
|
|
33
|
+
config: OpenClawConfig;
|
|
34
|
+
runtime: NapCatRuntimeEnv;
|
|
35
|
+
abortSignal: AbortSignal;
|
|
36
|
+
/** Port for the reverse WS server that NapCat connects to. */
|
|
37
|
+
wsPort: number;
|
|
38
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type NapCatCoreRuntime = ReturnType<typeof getNapCatRuntime>;
|
|
42
|
+
|
|
43
|
+
const QQ_TEXT_LIMIT = 4500;
|
|
44
|
+
|
|
45
|
+
/** Extract plain text from OneBot message segments, converting @mentions to readable form. */
|
|
46
|
+
function extractText(segments: OneBotSegment[]): string {
|
|
47
|
+
return segments
|
|
48
|
+
.map((s) => {
|
|
49
|
+
if (s.type === "text") return s.data.text ?? "";
|
|
50
|
+
if (s.type === "at") return `@${s.data.qq}`;
|
|
51
|
+
return "";
|
|
52
|
+
})
|
|
53
|
+
.join("")
|
|
54
|
+
.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Extract image URLs from OneBot message segments. */
|
|
58
|
+
function extractImageUrls(segments: OneBotSegment[]): string[] {
|
|
59
|
+
return segments
|
|
60
|
+
.filter((s) => s.type === "image")
|
|
61
|
+
.map((s) => s.data.url ?? s.data.file ?? "")
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Extract voice/record URL from OneBot message segments. */
|
|
66
|
+
function extractRecordUrl(segments: OneBotSegment[]): string | undefined {
|
|
67
|
+
const record = segments.find((s) => s.type === "record");
|
|
68
|
+
if (!record) return undefined;
|
|
69
|
+
return record.data.url ?? record.data.file ?? undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Download a URL and convert to wav using ffmpeg. Returns the wav file path. */
|
|
73
|
+
async function downloadAndConvertToWav(
|
|
74
|
+
url: string,
|
|
75
|
+
fetcher: (params: { url: string; maxBytes: number }) => Promise<{ buffer: Buffer; contentType: string }>,
|
|
76
|
+
maxBytes: number,
|
|
77
|
+
): Promise<string | undefined> {
|
|
78
|
+
const fetched = await fetcher({ url, maxBytes });
|
|
79
|
+
const ts = Date.now();
|
|
80
|
+
const inputPath = join(tmpdir(), `napcat-voice-${ts}.silk`);
|
|
81
|
+
const outputPath = join(tmpdir(), `napcat-voice-${ts}.wav`);
|
|
82
|
+
|
|
83
|
+
await writeFile(inputPath, fetched.buffer);
|
|
84
|
+
|
|
85
|
+
return new Promise<string | undefined>((resolve) => {
|
|
86
|
+
execFile(
|
|
87
|
+
"ffmpeg",
|
|
88
|
+
["-y", "-i", inputPath, "-ar", "16000", "-ac", "1", "-f", "wav", outputPath],
|
|
89
|
+
{ timeout: 15_000 },
|
|
90
|
+
async (err) => {
|
|
91
|
+
// Clean up input file
|
|
92
|
+
await unlink(inputPath).catch(() => {});
|
|
93
|
+
if (err) {
|
|
94
|
+
await unlink(outputPath).catch(() => {});
|
|
95
|
+
resolve(undefined);
|
|
96
|
+
} else {
|
|
97
|
+
resolve(outputPath);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Check if the message contains an @bot mention. */
|
|
105
|
+
function hasBotMention(segments: OneBotSegment[], selfId: string): boolean {
|
|
106
|
+
return segments.some(
|
|
107
|
+
(s) => s.type === "at" && s.data.qq === selfId,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Strip @bot mention segments and leading whitespace from text. */
|
|
112
|
+
function stripBotMention(segments: OneBotSegment[], selfId: string): OneBotSegment[] {
|
|
113
|
+
return segments.filter(
|
|
114
|
+
(s) => !(s.type === "at" && s.data.qq === selfId),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Determine if sender is allowed based on allowlist. */
|
|
119
|
+
function isNapCatSenderAllowed(
|
|
120
|
+
senderId: string,
|
|
121
|
+
allowFrom: Array<string | number>,
|
|
122
|
+
): boolean {
|
|
123
|
+
return allowFrom.some((entry) => String(entry) === senderId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Start a reverse WebSocket server for NapCat to connect to.
|
|
128
|
+
* NapCat will initiate the connection to this server.
|
|
129
|
+
*/
|
|
130
|
+
export async function monitorNapCatProvider(options: NapCatMonitorOptions): Promise<void> {
|
|
131
|
+
const { account, config, runtime, abortSignal, wsPort, statusSink } = options;
|
|
132
|
+
const core = getNapCatRuntime();
|
|
133
|
+
|
|
134
|
+
runtime.log?.(`[${account.accountId}] NapCat starting reverse WS server on port ${wsPort}`);
|
|
135
|
+
|
|
136
|
+
const server = createServer();
|
|
137
|
+
const wss = new WebSocketServer({ server });
|
|
138
|
+
|
|
139
|
+
let activeWs: WebSocket | null = null;
|
|
140
|
+
|
|
141
|
+
wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
|
|
142
|
+
const selfId = req.headers["x-self-id"];
|
|
143
|
+
runtime.log?.(`[${account.accountId}] NapCat connected, self_id=${selfId ?? "unknown"}`);
|
|
144
|
+
activeWs = ws;
|
|
145
|
+
|
|
146
|
+
ws.on("message", (raw: Buffer) => {
|
|
147
|
+
let data: Record<string, unknown>;
|
|
148
|
+
try {
|
|
149
|
+
data = JSON.parse(raw.toString()) as Record<string, unknown>;
|
|
150
|
+
} catch {
|
|
151
|
+
runtime.error?.(`[${account.accountId}] NapCat: invalid JSON from WS`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Ignore meta events (heartbeat, lifecycle)
|
|
156
|
+
if (data.post_type === "meta_event") return;
|
|
157
|
+
|
|
158
|
+
// Only handle message events
|
|
159
|
+
if (data.post_type !== "message") return;
|
|
160
|
+
|
|
161
|
+
const event = data as unknown as OneBotMessageEvent;
|
|
162
|
+
processMessage(event, account, config, runtime, core, statusSink).catch((err) => {
|
|
163
|
+
runtime.error?.(`[${account.accountId}] NapCat message processing error: ${String(err)}`);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
ws.on("close", () => {
|
|
168
|
+
runtime.log?.(`[${account.accountId}] NapCat WS disconnected`);
|
|
169
|
+
if (activeWs === ws) activeWs = null;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
ws.on("error", (err) => {
|
|
173
|
+
runtime.error?.(`[${account.accountId}] NapCat WS error: ${String(err)}`);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return new Promise<void>((resolve, reject) => {
|
|
178
|
+
server.listen(wsPort, "0.0.0.0", () => {
|
|
179
|
+
runtime.log?.(`[${account.accountId}] NapCat reverse WS server listening on ws://0.0.0.0:${wsPort}`);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
server.on("error", (err) => {
|
|
183
|
+
runtime.error?.(`[${account.accountId}] NapCat WS server error: ${String(err)}`);
|
|
184
|
+
reject(err);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Cleanup on abort
|
|
188
|
+
const onAbort = () => {
|
|
189
|
+
runtime.log?.(`[${account.accountId}] NapCat stopping WS server`);
|
|
190
|
+
wss.clients.forEach((client) => client.close());
|
|
191
|
+
wss.close();
|
|
192
|
+
server.close();
|
|
193
|
+
resolve();
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
if (abortSignal.aborted) {
|
|
197
|
+
onAbort();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function processMessage(
|
|
205
|
+
event: OneBotMessageEvent,
|
|
206
|
+
account: ResolvedNapCatAccount,
|
|
207
|
+
config: OpenClawConfig,
|
|
208
|
+
runtime: NapCatRuntimeEnv,
|
|
209
|
+
core: NapCatCoreRuntime,
|
|
210
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
const isGroup = event.message_type === "group";
|
|
213
|
+
const senderId = String(event.user_id);
|
|
214
|
+
const senderName = event.sender.card || event.sender.nickname;
|
|
215
|
+
const chatId = isGroup ? String(event.group_id) : senderId;
|
|
216
|
+
const selfId = account.selfId || String(event.self_id);
|
|
217
|
+
|
|
218
|
+
// In group chats, require @bot mention
|
|
219
|
+
if (isGroup && !hasBotMention(event.message, selfId)) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Strip @bot mention from message for processing
|
|
224
|
+
const cleanSegments = isGroup
|
|
225
|
+
? stripBotMention(event.message, selfId)
|
|
226
|
+
: event.message;
|
|
227
|
+
|
|
228
|
+
const text = extractText(cleanSegments);
|
|
229
|
+
const imageUrls = extractImageUrls(cleanSegments);
|
|
230
|
+
const recordUrl = extractRecordUrl(cleanSegments);
|
|
231
|
+
|
|
232
|
+
// Skip empty messages
|
|
233
|
+
if (!text && imageUrls.length === 0 && !recordUrl) return;
|
|
234
|
+
|
|
235
|
+
// Will be set if voice STT succeeds.
|
|
236
|
+
let voiceTranscript: string | undefined;
|
|
237
|
+
|
|
238
|
+
statusSink?.({ lastInboundAt: Date.now() });
|
|
239
|
+
|
|
240
|
+
// Download first image if present
|
|
241
|
+
let mediaPath: string | undefined;
|
|
242
|
+
let mediaType: string | undefined;
|
|
243
|
+
if (imageUrls.length > 0) {
|
|
244
|
+
try {
|
|
245
|
+
const mediaMaxMb = account.config.mediaMaxMb ?? 5;
|
|
246
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
247
|
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
248
|
+
url: imageUrls[0],
|
|
249
|
+
maxBytes,
|
|
250
|
+
});
|
|
251
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
252
|
+
fetched.buffer,
|
|
253
|
+
fetched.contentType,
|
|
254
|
+
"inbound",
|
|
255
|
+
maxBytes,
|
|
256
|
+
);
|
|
257
|
+
mediaPath = saved.path;
|
|
258
|
+
mediaType = saved.contentType;
|
|
259
|
+
} catch (err) {
|
|
260
|
+
runtime.error?.(`[${account.accountId}] Failed to download QQ image: ${String(err)}`);
|
|
261
|
+
}
|
|
262
|
+
} else if (recordUrl) {
|
|
263
|
+
// Voice message: download → ffmpeg wav → STT transcribe → text
|
|
264
|
+
try {
|
|
265
|
+
const mediaMaxMb = account.config.mediaMaxMb ?? 5;
|
|
266
|
+
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
267
|
+
const wavPath = await downloadAndConvertToWav(
|
|
268
|
+
recordUrl,
|
|
269
|
+
core.channel.media.fetchRemoteMedia,
|
|
270
|
+
maxBytes,
|
|
271
|
+
);
|
|
272
|
+
if (wavPath) {
|
|
273
|
+
try {
|
|
274
|
+
const result = await core.stt.transcribeAudioFile({
|
|
275
|
+
filePath: wavPath,
|
|
276
|
+
cfg: config,
|
|
277
|
+
mime: "audio/wav",
|
|
278
|
+
});
|
|
279
|
+
if (result.text) {
|
|
280
|
+
// Transcription succeeded — use text instead of raw audio.
|
|
281
|
+
voiceTranscript = result.text;
|
|
282
|
+
await unlink(wavPath).catch(() => {});
|
|
283
|
+
runtime.log?.(`[${account.accountId}] Voice transcribed: ${result.text.slice(0, 80)}`);
|
|
284
|
+
} else {
|
|
285
|
+
// STT returned empty; fall back to passing audio file.
|
|
286
|
+
mediaPath = wavPath;
|
|
287
|
+
mediaType = "audio/wav";
|
|
288
|
+
}
|
|
289
|
+
} catch (sttErr) {
|
|
290
|
+
runtime.error?.(`[${account.accountId}] STT failed, falling back to audio: ${String(sttErr)}`);
|
|
291
|
+
mediaPath = wavPath;
|
|
292
|
+
mediaType = "audio/wav";
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
runtime.error?.(`[${account.accountId}] ffmpeg voice conversion failed`);
|
|
296
|
+
}
|
|
297
|
+
} catch (err) {
|
|
298
|
+
runtime.error?.(`[${account.accountId}] Failed to process QQ voice: ${String(err)}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Authorization pipeline
|
|
303
|
+
const pairing = createScopedPairingAccess({
|
|
304
|
+
core,
|
|
305
|
+
channel: "napcat",
|
|
306
|
+
accountId: account.accountId,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const dmPolicy = account.config.dmPolicy ?? "allowlist";
|
|
310
|
+
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
|
311
|
+
const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
|
|
312
|
+
const groupAllowFrom =
|
|
313
|
+
configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom;
|
|
314
|
+
|
|
315
|
+
// Group access control
|
|
316
|
+
if (isGroup) {
|
|
317
|
+
const groupPolicy = account.config.groupPolicy ?? resolveDefaultGroupPolicy(config);
|
|
318
|
+
if (groupPolicy === "disabled") {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (groupPolicy === "allowlist") {
|
|
322
|
+
if (!isNapCatSenderAllowed(senderId, groupAllowFrom)) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Merge voice transcript into message body when available.
|
|
329
|
+
const effectiveText = voiceTranscript
|
|
330
|
+
? (text ? `${text}\n[语音转文字] ${voiceTranscript}` : `[语音转文字] ${voiceTranscript}`)
|
|
331
|
+
: text;
|
|
332
|
+
const rawBody = effectiveText || (mediaPath ? "<media:image>" : "");
|
|
333
|
+
const { senderAllowedForCommands, commandAuthorized } =
|
|
334
|
+
await resolveSenderCommandAuthorizationWithRuntime({
|
|
335
|
+
cfg: config,
|
|
336
|
+
rawBody,
|
|
337
|
+
isGroup,
|
|
338
|
+
dmPolicy,
|
|
339
|
+
configuredAllowFrom: configAllowFrom,
|
|
340
|
+
configuredGroupAllowFrom: groupAllowFrom,
|
|
341
|
+
senderId,
|
|
342
|
+
isSenderAllowed: isNapCatSenderAllowed,
|
|
343
|
+
readAllowFromStore: pairing.readAllowFromStore,
|
|
344
|
+
runtime: core.channel.commands,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// DM authorization
|
|
348
|
+
const directDmOutcome = resolveDirectDmAuthorizationOutcome({
|
|
349
|
+
isGroup,
|
|
350
|
+
dmPolicy,
|
|
351
|
+
senderAllowedForCommands,
|
|
352
|
+
});
|
|
353
|
+
if (directDmOutcome === "disabled") return;
|
|
354
|
+
if (directDmOutcome === "unauthorized") {
|
|
355
|
+
if (dmPolicy === "pairing") {
|
|
356
|
+
await issuePairingChallenge({
|
|
357
|
+
channel: "napcat",
|
|
358
|
+
senderId,
|
|
359
|
+
senderIdLine: `Your QQ number: ${senderId}`,
|
|
360
|
+
meta: { name: senderName ?? undefined },
|
|
361
|
+
upsertPairingRequest: pairing.upsertPairingRequest,
|
|
362
|
+
onCreated: () => {
|
|
363
|
+
runtime.log?.(`[${account.accountId}] napcat pairing request sender=${senderId}`);
|
|
364
|
+
},
|
|
365
|
+
sendPairingReply: async (replyText) => {
|
|
366
|
+
try {
|
|
367
|
+
await sendPrivateMsg(
|
|
368
|
+
account.httpApi,
|
|
369
|
+
Number(senderId),
|
|
370
|
+
[textSegment(replyText)],
|
|
371
|
+
account.accessToken,
|
|
372
|
+
);
|
|
373
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
374
|
+
} catch (err) {
|
|
375
|
+
runtime.error?.(`[${account.accountId}] pairing reply failed: ${String(err)}`);
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
onReplyError: (err) => {
|
|
379
|
+
runtime.error?.(`[${account.accountId}] pairing reply error: ${String(err)}`);
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Route resolution — group sessions are per-user so each sender has isolated context.
|
|
387
|
+
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
|
388
|
+
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
|
389
|
+
cfg: config,
|
|
390
|
+
channel: "napcat",
|
|
391
|
+
accountId: account.accountId,
|
|
392
|
+
peer: {
|
|
393
|
+
kind: isGroup ? "group" : "direct",
|
|
394
|
+
id: isGroup ? `${chatId}:${senderId}` : chatId,
|
|
395
|
+
},
|
|
396
|
+
runtime: core.channel,
|
|
397
|
+
sessionStore: config.session?.store,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Block unauthorized control commands in groups
|
|
401
|
+
if (
|
|
402
|
+
isGroup &&
|
|
403
|
+
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
|
404
|
+
commandAuthorized !== true
|
|
405
|
+
) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const { storePath, body } = buildEnvelope({
|
|
410
|
+
channel: "QQ",
|
|
411
|
+
from: fromLabel,
|
|
412
|
+
timestamp: event.time ? event.time * 1000 : undefined,
|
|
413
|
+
body: rawBody,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const targetPrefix = isGroup ? `napcat:group:${chatId}` : `napcat:${senderId}`;
|
|
417
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
418
|
+
Body: body,
|
|
419
|
+
BodyForAgent: rawBody,
|
|
420
|
+
RawBody: rawBody,
|
|
421
|
+
CommandBody: rawBody,
|
|
422
|
+
From: targetPrefix,
|
|
423
|
+
To: isGroup ? `napcat:group:${chatId}` : `napcat:${chatId}`,
|
|
424
|
+
SessionKey: route.sessionKey,
|
|
425
|
+
AccountId: route.accountId,
|
|
426
|
+
ChatType: isGroup ? "group" : "direct",
|
|
427
|
+
ConversationLabel: fromLabel,
|
|
428
|
+
SenderName: senderName || undefined,
|
|
429
|
+
SenderId: senderId,
|
|
430
|
+
CommandAuthorized: commandAuthorized,
|
|
431
|
+
Provider: "napcat",
|
|
432
|
+
Surface: "napcat",
|
|
433
|
+
MessageSid: String(event.message_id),
|
|
434
|
+
MediaPath: mediaPath,
|
|
435
|
+
MediaType: mediaType,
|
|
436
|
+
MediaUrl: mediaPath,
|
|
437
|
+
OriginatingChannel: "napcat",
|
|
438
|
+
OriginatingTo: isGroup ? `napcat:group:${chatId}` : `napcat:${chatId}`,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await core.channel.session.recordInboundSession({
|
|
442
|
+
storePath,
|
|
443
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
444
|
+
ctx: ctxPayload,
|
|
445
|
+
onRecordError: (err) => {
|
|
446
|
+
runtime.error?.(`napcat: failed updating session meta: ${String(err)}`);
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
451
|
+
cfg: config,
|
|
452
|
+
channel: "napcat",
|
|
453
|
+
accountId: account.accountId,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
457
|
+
cfg: config,
|
|
458
|
+
agentId: route.agentId,
|
|
459
|
+
channel: "napcat",
|
|
460
|
+
accountId: account.accountId,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const typingCallbacks = createTypingCallbacks({
|
|
464
|
+
start: async () => {
|
|
465
|
+
// QQ doesn't have a "typing" indicator via OneBot, no-op
|
|
466
|
+
},
|
|
467
|
+
onStartError: () => {},
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
471
|
+
ctx: ctxPayload,
|
|
472
|
+
cfg: config,
|
|
473
|
+
dispatcherOptions: {
|
|
474
|
+
...prefixOptions,
|
|
475
|
+
typingCallbacks,
|
|
476
|
+
deliver: async (payload) => {
|
|
477
|
+
await deliverNapCatReply({
|
|
478
|
+
payload,
|
|
479
|
+
account,
|
|
480
|
+
chatId,
|
|
481
|
+
isGroup,
|
|
482
|
+
runtime,
|
|
483
|
+
core,
|
|
484
|
+
config,
|
|
485
|
+
statusSink,
|
|
486
|
+
tableMode,
|
|
487
|
+
replyToMessageId: event.message_id,
|
|
488
|
+
});
|
|
489
|
+
},
|
|
490
|
+
onError: (err, info) => {
|
|
491
|
+
runtime.error?.(`[${account.accountId}] NapCat ${info.kind} reply failed: ${String(err)}`);
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
replyOptions: {
|
|
495
|
+
onModelSelected,
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function deliverNapCatReply(params: {
|
|
501
|
+
payload: { text?: string; mediaUrls?: string[] };
|
|
502
|
+
account: ResolvedNapCatAccount;
|
|
503
|
+
chatId: string;
|
|
504
|
+
isGroup: boolean;
|
|
505
|
+
runtime: NapCatRuntimeEnv;
|
|
506
|
+
core: NapCatCoreRuntime;
|
|
507
|
+
config: OpenClawConfig;
|
|
508
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
509
|
+
tableMode?: string;
|
|
510
|
+
/** Original message ID to quote-reply in group chats. */
|
|
511
|
+
replyToMessageId?: number;
|
|
512
|
+
}): Promise<void> {
|
|
513
|
+
const { payload, account, chatId, isGroup, runtime, core, config, statusSink } = params;
|
|
514
|
+
const tableMode = params.tableMode ?? "code";
|
|
515
|
+
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
516
|
+
const mediaUrls = resolveOutboundMediaUrls(payload);
|
|
517
|
+
|
|
518
|
+
// In group chats, prepend a reply segment to quote the original message.
|
|
519
|
+
const replyPrefix: OneBotSegment[] =
|
|
520
|
+
isGroup && params.replyToMessageId
|
|
521
|
+
? [replySegment(params.replyToMessageId)]
|
|
522
|
+
: [];
|
|
523
|
+
|
|
524
|
+
const mediaSegments: OneBotSegment[] = [];
|
|
525
|
+
for (const url of mediaUrls) {
|
|
526
|
+
mediaSegments.push({ type: "image", data: { file: url } });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Add text (chunked if needed)
|
|
530
|
+
if (text) {
|
|
531
|
+
const chunkMode = core.channel.text.resolveChunkMode(config, "napcat", account.accountId);
|
|
532
|
+
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, QQ_TEXT_LIMIT, chunkMode);
|
|
533
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
534
|
+
// First chunk gets reply-quote + images; subsequent chunks are plain text.
|
|
535
|
+
const toSend =
|
|
536
|
+
i === 0
|
|
537
|
+
? [...replyPrefix, ...mediaSegments, textSegment(chunks[i])]
|
|
538
|
+
: [textSegment(chunks[i])];
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
if (isGroup) {
|
|
542
|
+
await sendGroupMsg(account.httpApi, Number(chatId), toSend, account.accessToken);
|
|
543
|
+
} else {
|
|
544
|
+
await sendPrivateMsg(account.httpApi, Number(chatId), toSend, account.accessToken);
|
|
545
|
+
}
|
|
546
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
547
|
+
} catch (err) {
|
|
548
|
+
runtime.error?.(`NapCat message send failed: ${String(err)}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
} else if (mediaSegments.length > 0) {
|
|
552
|
+
// Media only, no text — still quote the original message.
|
|
553
|
+
const toSend = [...replyPrefix, ...mediaSegments];
|
|
554
|
+
try {
|
|
555
|
+
if (isGroup) {
|
|
556
|
+
await sendGroupMsg(account.httpApi, Number(chatId), toSend, account.accessToken);
|
|
557
|
+
} else {
|
|
558
|
+
await sendPrivateMsg(account.httpApi, Number(chatId), toSend, account.accessToken);
|
|
559
|
+
}
|
|
560
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
561
|
+
} catch (err) {
|
|
562
|
+
runtime.error?.(`NapCat media send failed: ${String(err)}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
|
2
|
+
import { getLoginInfo } from "./api.js";
|
|
3
|
+
import type { OneBotLoginInfo } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type NapCatProbeResult = BaseProbeResult<string> & {
|
|
6
|
+
bot?: OneBotLoginInfo;
|
|
7
|
+
elapsedMs: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Probe NapCat HTTP API to verify connectivity and get bot info.
|
|
12
|
+
*/
|
|
13
|
+
export async function probeNapCat(
|
|
14
|
+
httpApi: string,
|
|
15
|
+
accessToken?: string,
|
|
16
|
+
timeoutMs = 5000,
|
|
17
|
+
): Promise<NapCatProbeResult> {
|
|
18
|
+
if (!httpApi) {
|
|
19
|
+
return { ok: false, error: "No NapCat HTTP API URL configured", elapsedMs: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const start = Date.now();
|
|
23
|
+
try {
|
|
24
|
+
const bot = await getLoginInfo(httpApi, accessToken);
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
bot,
|
|
28
|
+
elapsedMs: Date.now() - start,
|
|
29
|
+
};
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
error: err instanceof Error ? err.message : String(err),
|
|
34
|
+
elapsedMs: Date.now() - start,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
const { setRuntime: setNapCatRuntime, getRuntime: getNapCatRuntime } =
|
|
5
|
+
createPluginRuntimeStore<PluginRuntime>("NapCat runtime not initialized");
|
|
6
|
+
export { getNapCatRuntime, setNapCatRuntime };
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { resolveNapCatAccount } from "./accounts.js";
|
|
3
|
+
import {
|
|
4
|
+
sendPrivateMsg,
|
|
5
|
+
sendGroupMsg,
|
|
6
|
+
textSegment,
|
|
7
|
+
imageSegment,
|
|
8
|
+
} from "./api.js";
|
|
9
|
+
import type { OneBotSegment } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export type NapCatSendOptions = {
|
|
12
|
+
httpApi?: string;
|
|
13
|
+
accessToken?: string;
|
|
14
|
+
accountId?: string;
|
|
15
|
+
cfg?: OpenClawConfig;
|
|
16
|
+
mediaUrl?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type NapCatSendResult = {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
messageId?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const QQ_TEXT_LIMIT = 4500;
|
|
26
|
+
|
|
27
|
+
function resolveSendContext(options: NapCatSendOptions): {
|
|
28
|
+
httpApi: string;
|
|
29
|
+
accessToken: string;
|
|
30
|
+
} {
|
|
31
|
+
if (options.cfg) {
|
|
32
|
+
const account = resolveNapCatAccount({
|
|
33
|
+
cfg: options.cfg,
|
|
34
|
+
accountId: options.accountId,
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
httpApi: options.httpApi || account.httpApi,
|
|
38
|
+
accessToken: options.accessToken || account.accessToken,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
httpApi: options.httpApi ?? "",
|
|
43
|
+
accessToken: options.accessToken ?? "",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Send a message to a QQ user or group.
|
|
49
|
+
* Target format: "private:<userId>" or "group:<groupId>"
|
|
50
|
+
*/
|
|
51
|
+
export async function sendMessageNapCat(
|
|
52
|
+
target: string,
|
|
53
|
+
text: string,
|
|
54
|
+
options: NapCatSendOptions = {},
|
|
55
|
+
): Promise<NapCatSendResult> {
|
|
56
|
+
const { httpApi, accessToken } = resolveSendContext(options);
|
|
57
|
+
if (!httpApi) {
|
|
58
|
+
return { ok: false, error: "No NapCat HTTP API URL configured" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const trimmedTarget = target?.trim();
|
|
62
|
+
if (!trimmedTarget) {
|
|
63
|
+
return { ok: false, error: "No target provided" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Build message segments
|
|
67
|
+
const segments: OneBotSegment[] = [];
|
|
68
|
+
|
|
69
|
+
if (options.mediaUrl?.trim()) {
|
|
70
|
+
segments.push(imageSegment(options.mediaUrl.trim()));
|
|
71
|
+
}
|
|
72
|
+
if (text?.trim()) {
|
|
73
|
+
segments.push(textSegment(text.slice(0, QQ_TEXT_LIMIT)));
|
|
74
|
+
}
|
|
75
|
+
if (segments.length === 0) {
|
|
76
|
+
return { ok: false, error: "Empty message" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const isGroup = trimmedTarget.startsWith("group:");
|
|
81
|
+
const id = Number(trimmedTarget.replace(/^(private|group):/, ""));
|
|
82
|
+
|
|
83
|
+
if (Number.isNaN(id)) {
|
|
84
|
+
return { ok: false, error: `Invalid target ID: ${trimmedTarget}` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = isGroup
|
|
88
|
+
? await sendGroupMsg(httpApi, id, segments, accessToken)
|
|
89
|
+
: await sendPrivateMsg(httpApi, id, segments, accessToken);
|
|
90
|
+
|
|
91
|
+
return { ok: true, messageId: String(result.message_id) };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
94
|
+
}
|
|
95
|
+
}
|