@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/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
+ }