@izhimu/qq 0.1.1 → 0.2.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.
@@ -4,5 +4,5 @@
4
4
  * Optimized for maintainability with clear structure and minimal duplication.
5
5
  */
6
6
  import type { NapCatMessage, OpenClawMessage } from '../types/index.js';
7
- export declare function openClawToNapCatMessage(content: OpenClawMessage[], replyToId?: string): NapCatMessage[];
7
+ export declare function openClawToNapCatMessage(content: OpenClawMessage[]): NapCatMessage[];
8
8
  export declare function napCatToOpenClawMessage(segments: NapCatMessage[] | string): Promise<OpenClawMessage[]>;
@@ -117,20 +117,20 @@ function openClawSegmentToNapCat(content) {
117
117
  return { type: 'image', data: { file: content.url, url: content.url } };
118
118
  case 'reply':
119
119
  return { type: 'reply', data: { id: content.messageId } };
120
+ case 'file':
121
+ return { type: 'file', data: { file: content.file, url: content.url, file_size: content.fileSize } };
120
122
  case 'audio':
121
- // These types are inbound-only for now
122
- log.warn('adapters', `Unsupported outbound type: ${content.type}`);
123
- return null;
123
+ return {
124
+ type: 'record',
125
+ data: { file: content.file, path: content.path, url: content.url, file_size: content.fileSize }
126
+ };
124
127
  default:
125
128
  log.warn('adapters', `Unknown content type (outbound): ${content.type}`);
126
129
  return null;
127
130
  }
128
131
  }
129
- export function openClawToNapCatMessage(content, replyToId) {
132
+ export function openClawToNapCatMessage(content) {
130
133
  const segments = [];
131
- if (replyToId) {
132
- segments.push({ type: 'reply', data: { id: replyToId } });
133
- }
134
134
  for (const item of content) {
135
135
  const segment = openClawSegmentToNapCat(item);
136
136
  if (segment) {
@@ -3,7 +3,7 @@
3
3
  * Main plugin entry point
4
4
  */
5
5
  import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk";
6
- import { messageIdToString, Logger as log } from "./utils/index.js";
6
+ import { messageIdToString, getFileType, getFileName, Logger as log } from "./utils/index.js";
7
7
  import { setContext, setContextStatus, clearContext, setConnection, getConnection, clearConnection } from "./core/runtime.js";
8
8
  import { ConnectionManager } from "./core/connection.js";
9
9
  import { openClawToNapCatMessage } from "./adapters/message.js";
@@ -53,203 +53,8 @@ export const qqPlugin = {
53
53
  },
54
54
  outbound: {
55
55
  deliveryMode: "direct",
56
- sendText: async (ctx) => {
57
- const { to, text, accountId, cfg, replyToId } = ctx;
58
- if (!accountId) {
59
- return {
60
- channel: CHANNEL_ID,
61
- messageId: "",
62
- error: new Error("accountId is required"),
63
- deliveredAt: Date.now(),
64
- };
65
- }
66
- const account = resolveQQAccount({ cfg });
67
- if (!account) {
68
- return {
69
- channel: CHANNEL_ID,
70
- messageId: "",
71
- error: new Error(`Account not found: ${accountId}`),
72
- deliveredAt: Date.now(),
73
- };
74
- }
75
- const connection = getConnection();
76
- if (!connection?.isConnected()) {
77
- return {
78
- channel: CHANNEL_ID,
79
- messageId: "",
80
- error: new Error(`Not connected for account: ${accountId}`),
81
- deliveredAt: Date.now(),
82
- };
83
- }
84
- // Parse target (format: private:xxx or group:xxx)
85
- const parts = to.split(":");
86
- const type = parts[0];
87
- const id = parts[1];
88
- const chatType = type === "group" ? "group" : "direct";
89
- const chatId = id || to;
90
- try {
91
- const messageSegments = openClawToNapCatMessage([{ type: "text", text }], replyToId ?? undefined);
92
- const response = await sendMsg({
93
- message_type: chatType === "direct" ? "private" : "group",
94
- user_id: chatType === "direct" ? chatId : undefined,
95
- group_id: chatType === "group" ? chatId : undefined,
96
- message: messageSegments,
97
- });
98
- // Update lastOutboundAt timestamp on successful send
99
- if (response.status === "ok") {
100
- setContextStatus({
101
- lastOutboundAt: Date.now(),
102
- });
103
- }
104
- if (response.status === "ok" && response.data) {
105
- const data = response.data;
106
- return {
107
- channel: CHANNEL_ID,
108
- messageId: messageIdToString(data.message_id),
109
- deliveredAt: Date.now(),
110
- };
111
- }
112
- else {
113
- return {
114
- channel: CHANNEL_ID,
115
- messageId: "",
116
- error: new Error(response.msg || "Send failed"),
117
- deliveredAt: Date.now(),
118
- };
119
- }
120
- }
121
- catch (error) {
122
- const errorMessage = error instanceof Error ? error.message : String(error);
123
- return {
124
- channel: CHANNEL_ID,
125
- messageId: "",
126
- error: new Error(errorMessage),
127
- deliveredAt: Date.now(),
128
- };
129
- }
130
- },
131
- sendMedia: async (ctx) => {
132
- const { to, mediaUrl, accountId, cfg, replyToId } = ctx;
133
- log.debug("outbound", `sendMedia called - accountId: ${accountId}, to: ${to}, mediaUrl: ${mediaUrl ?? "null"}, replyToId: ${replyToId ?? "none"}`);
134
- if (!accountId) {
135
- log.warn("outbound", "sendMedia failed: accountId is required");
136
- return {
137
- channel: CHANNEL_ID,
138
- messageId: "",
139
- error: new Error("accountId is required"),
140
- deliveredAt: Date.now(),
141
- };
142
- }
143
- // Validate mediaUrl - check for null, undefined, empty string, or invalid URL
144
- if (mediaUrl === null || mediaUrl === undefined || mediaUrl === "") {
145
- log.warn("outbound", `sendMedia failed: mediaUrl is invalid (value: ${String(mediaUrl)})`);
146
- return {
147
- channel: CHANNEL_ID,
148
- messageId: "",
149
- error: new Error(`mediaUrl is required but received: ${mediaUrl === null ? "null" : mediaUrl === undefined ? "undefined" : "empty string"}`),
150
- deliveredAt: Date.now(),
151
- };
152
- }
153
- // Check if mediaUrl looks like a valid URL or file path
154
- const trimmedUrl = String(mediaUrl).trim();
155
- if (trimmedUrl === "" || trimmedUrl.length < 3) {
156
- log.warn("outbound", `sendMedia failed: mediaUrl is too short or empty after trim`);
157
- return {
158
- channel: CHANNEL_ID,
159
- messageId: "",
160
- error: new Error(`mediaUrl appears to be invalid: "${trimmedUrl}"`),
161
- deliveredAt: Date.now(),
162
- };
163
- }
164
- const account = resolveQQAccount({ cfg });
165
- if (!account) {
166
- log.warn("outbound", `sendMedia failed: Account not found: ${accountId}`);
167
- return {
168
- channel: CHANNEL_ID,
169
- messageId: "",
170
- error: new Error(`Account not found: ${accountId}`),
171
- deliveredAt: Date.now(),
172
- };
173
- }
174
- const connection = getConnection();
175
- if (!connection?.isConnected()) {
176
- log.warn("outbound", `sendMedia failed: Not connected for account: ${accountId}`);
177
- return {
178
- channel: CHANNEL_ID,
179
- messageId: "",
180
- error: new Error(`Not connected for account: ${accountId}`),
181
- deliveredAt: Date.now(),
182
- };
183
- }
184
- // Parse target (format: private:xxx or group:xxx)
185
- const parts = to.split(":");
186
- const type = parts[0];
187
- const id = parts[1];
188
- const chatType = type === "group" ? "group" : "direct";
189
- const chatId = id || to;
190
- log.debug("outbound", `Sending media to ${chatType}:${chatId}, url: ${trimmedUrl.substring(0, 100)}${trimmedUrl.length > 100 ? "..." : ""}`);
191
- try {
192
- // Build media segment - NapCat requires 'file' field
193
- // file can be: URL, file path, or base64
194
- const mediaSegment = {
195
- type: "image",
196
- data: {
197
- file: trimmedUrl,
198
- url: trimmedUrl,
199
- summary: "[图片]",
200
- },
201
- };
202
- // Build message segments with optional reply
203
- const messageSegments = [];
204
- if (replyToId) {
205
- messageSegments.push({ type: "reply", data: { id: replyToId } });
206
- }
207
- messageSegments.push(mediaSegment);
208
- log.debug("outbound", `Message segments: ${JSON.stringify(messageSegments)}`);
209
- const response = await sendMsg({
210
- message_type: chatType === "direct" ? "private" : "group",
211
- user_id: chatType === "direct" ? chatId : undefined,
212
- group_id: chatType === "group" ? chatId : undefined,
213
- message: messageSegments,
214
- });
215
- log.debug("outbound", `NapCat response - status: ${response.status}, retcode: ${response.retcode}, msg: ${response.msg ?? "none"}, data: ${JSON.stringify(response.data)}`);
216
- // Update lastOutboundAt timestamp on successful send
217
- if (response.status === "ok") {
218
- setContextStatus({
219
- lastOutboundAt: Date.now(),
220
- });
221
- }
222
- if (response.status === "ok" && response.data) {
223
- const data = response.data;
224
- log.debug("outbound", `Media sent successfully, message_id: ${data.message_id}`);
225
- return {
226
- channel: CHANNEL_ID,
227
- messageId: messageIdToString(data.message_id),
228
- deliveredAt: Date.now(),
229
- };
230
- }
231
- else {
232
- const errorMsg = response.msg || "Send media failed";
233
- log.warn("outbound", `sendMedia failed - status: ${response.status}, retcode: ${response.retcode}, msg: ${errorMsg}`);
234
- return {
235
- channel: CHANNEL_ID,
236
- messageId: "",
237
- error: new Error(`NapCat error [${response.retcode ?? "unknown"}]: ${errorMsg}`),
238
- deliveredAt: Date.now(),
239
- };
240
- }
241
- }
242
- catch (error) {
243
- const errorMessage = error instanceof Error ? error.message : String(error);
244
- log.error("outbound", `sendMedia exception: ${errorMessage}`);
245
- return {
246
- channel: CHANNEL_ID,
247
- messageId: "",
248
- error: new Error(`sendMedia error: ${errorMessage}`),
249
- deliveredAt: Date.now(),
250
- };
251
- }
252
- },
56
+ sendText: outboundSend,
57
+ sendMedia: outboundSend,
253
58
  },
254
59
  status: {
255
60
  buildAccountSnapshot: ({ account, runtime }) => {
@@ -310,3 +115,65 @@ export const qqPlugin = {
310
115
  },
311
116
  },
312
117
  };
118
+ async function outboundSend(ctx) {
119
+ const { to, text, mediaUrl, accountId, replyToId } = ctx;
120
+ log.debug("outbound", `send called - accountId: ${accountId}, to: ${to}, mediaUrl: ${mediaUrl ?? "null"}, replyToId: ${replyToId ?? "none"}`);
121
+ // Parse target (format: private:xxx or group:xxx)
122
+ const parts = to.split(":");
123
+ const [type, id] = parts.length > 1 ? parts : ["private", to];
124
+ const chatType = type === "group" ? "group" : "private";
125
+ const chatId = id || to;
126
+ const content = [];
127
+ if (text) {
128
+ content.push({ type: "text", text });
129
+ }
130
+ if (mediaUrl) {
131
+ switch (getFileType(mediaUrl)) {
132
+ case "image":
133
+ content.push({ type: "image", url: mediaUrl.trim() });
134
+ break;
135
+ case "audio":
136
+ content.push({ type: "audio", path: mediaUrl.trim(), url: mediaUrl.trim(), file: getFileName(mediaUrl.trim()) });
137
+ break;
138
+ default:
139
+ content.push({ type: "file", url: mediaUrl.trim(), file: getFileName(mediaUrl.trim()) });
140
+ }
141
+ }
142
+ if (replyToId) {
143
+ content.push({ type: "reply", messageId: replyToId });
144
+ }
145
+ if (content.length === 0) {
146
+ log.warn("outbound", `send called with no content - accountId: ${accountId}, to: ${to}, mediaUrl: ${mediaUrl ?? "null"}, replyToId: ${replyToId ?? "none"}`);
147
+ return {
148
+ channel: CHANNEL_ID,
149
+ messageId: "",
150
+ error: new Error(`No content to send`),
151
+ deliveredAt: Date.now(),
152
+ };
153
+ }
154
+ const response = await sendMsg({
155
+ message_type: chatType,
156
+ user_id: chatType === "private" ? chatId : undefined,
157
+ group_id: chatType === "group" ? chatId : undefined,
158
+ message: openClawToNapCatMessage(content),
159
+ });
160
+ if (response.status === "ok" && response.data) {
161
+ setContextStatus({ lastOutboundAt: Date.now() });
162
+ const data = response.data;
163
+ log.debug("outbound", `send successfully, messageId: ${data.message_id}`);
164
+ return {
165
+ channel: CHANNEL_ID,
166
+ messageId: messageIdToString(data.message_id),
167
+ deliveredAt: Date.now(),
168
+ };
169
+ }
170
+ else {
171
+ log.warn("outbound", `send failed, status: ${response.status}, retcode: ${response.retcode}, msg: ${response.msg ?? "none"}`);
172
+ return {
173
+ channel: CHANNEL_ID,
174
+ messageId: "",
175
+ error: new Error(response.msg || "Send failed"),
176
+ deliveredAt: Date.now(),
177
+ };
178
+ }
179
+ }
@@ -18,7 +18,7 @@ async function contentToPlainText(content) {
18
18
  .map((c) => {
19
19
  switch (c.type) {
20
20
  case 'text':
21
- return c.text;
21
+ return `[消息]\n${c.text}`;
22
22
  case 'at':
23
23
  return c.isAll ? '@全体成员' : `@${c.userId}`;
24
24
  case 'json':
@@ -63,6 +63,7 @@ async function contextToMedia(content) {
63
63
  }
64
64
  return;
65
65
  }
66
+ // TODO弃用
66
67
  async function contextToReply(content) {
67
68
  const hasReply = content.some(c => c.type === 'reply');
68
69
  if (!hasReply) {
@@ -86,6 +87,22 @@ async function contextToReply(content) {
86
87
  sender: String(response.data?.sender.user_id)
87
88
  };
88
89
  }
90
+ async function sendText(isGroup, chatId, text) {
91
+ const cleanText = text.replace(/NO_REPLY\s*$/, '');
92
+ const messageSegments = [{ type: 'text', data: { text: markdownToText(cleanText) } }];
93
+ try {
94
+ await sendMsg({
95
+ message_type: isGroup ? 'group' : 'private',
96
+ group_id: isGroup ? chatId : undefined,
97
+ user_id: !isGroup ? chatId : undefined,
98
+ message: messageSegments,
99
+ });
100
+ log.info('dispatch', `Sent reply: ${text.slice(0, 100)}`);
101
+ }
102
+ catch (error) {
103
+ log.error('dispatch', `Send failed: ${error}`);
104
+ }
105
+ }
89
106
  /**
90
107
  * Dispatch an incoming message to the AI for processing
91
108
  */
@@ -103,13 +120,7 @@ export async function dispatchMessage(params) {
103
120
  }
104
121
  const isGroup = chatType === 'group';
105
122
  const peerId = isGroup ? `group:${chatId}` : senderId;
106
- if (!isGroup) {
107
- // 输入状态
108
- await setInputStatus({
109
- user_id: senderId,
110
- event_type: 1
111
- });
112
- }
123
+ const fullContent = `${content}\n\nFrom QQ(${senderId}) - Nickname: ${senderName}`;
113
124
  const route = runtime.channel.routing.resolveAgentRoute({
114
125
  cfg: context.cfg,
115
126
  channel: CHANNEL_ID,
@@ -122,7 +133,7 @@ export async function dispatchMessage(params) {
122
133
  const body = runtime.channel.reply.formatInboundEnvelope({
123
134
  channel: CHANNEL_ID,
124
135
  from: senderName || senderId,
125
- body: content,
136
+ body: fullContent,
126
137
  timestamp,
127
138
  chatType: isGroup ? 'group' : 'direct',
128
139
  sender: {
@@ -135,8 +146,8 @@ export async function dispatchMessage(params) {
135
146
  const toAddress = `qq:${route.accountId}`;
136
147
  const ctxPayload = runtime.channel.reply.finalizeInboundContext({
137
148
  Body: body,
138
- RawBody: content,
139
- CommandBody: content,
149
+ RawBody: fullContent,
150
+ CommandBody: fullContent,
140
151
  From: fromAddress,
141
152
  To: toAddress,
142
153
  SessionKey: route.sessionKey,
@@ -159,24 +170,7 @@ export async function dispatchMessage(params) {
159
170
  OriginatingTo: toAddress,
160
171
  });
161
172
  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
173
  const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(context.cfg, route.agentId);
178
- log.info('dispatch', `Messages config: ${JSON.stringify(messagesConfig)}`);
179
- let hasResponse = false;
180
174
  try {
181
175
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
182
176
  ctx: ctxPayload,
@@ -186,22 +180,29 @@ export async function dispatchMessage(params) {
186
180
  mode: "off"
187
181
  },
188
182
  responsePrefix: messagesConfig.responsePrefix,
183
+ onReplyStart: async () => {
184
+ if (!isGroup) {
185
+ // 输入状态
186
+ await setInputStatus({
187
+ user_id: senderId,
188
+ event_type: 1
189
+ });
190
+ }
191
+ },
189
192
  deliver: async (payload, info) => {
190
- hasResponse = true;
191
193
  log.info('dispatch', `deliver(${info.kind}): ${JSON.stringify(payload)}`);
192
194
  if (payload.text) {
193
- await sendReply(payload.text);
195
+ await sendText(isGroup, chatId, payload.text);
194
196
  }
195
197
  },
196
198
  onError: async (err) => {
197
- hasResponse = true;
198
199
  log.error('dispatch', `Dispatch error: ${err}`);
199
- await sendReply(`[错误] ${String(err)}`);
200
+ await sendText(isGroup, chatId, `[错误]\n${String(err)}`);
200
201
  },
201
202
  },
202
203
  replyOptions: {},
203
204
  });
204
- log.info('dispatch', `Dispatch completed, hasResponse: ${hasResponse}`);
205
+ log.info('dispatch', `Dispatch completed`);
205
206
  }
206
207
  catch (error) {
207
208
  log.error('dispatch', `Message processing failed: ${error}`);
@@ -279,7 +280,7 @@ export async function handlePokeEvent(event) {
279
280
  senderId: String(event.user_id),
280
281
  senderName: String(event.user_id),
281
282
  messageId: `poke_${event.user_id}_${Date.now()}`,
282
- content: `[动作] ${pokeMessage}`,
283
+ content: `[动作]\n${pokeMessage}`,
283
284
  timestamp: Date.now(),
284
285
  });
285
286
  }
@@ -141,7 +141,9 @@ export interface OpenClawJsonContent {
141
141
  }
142
142
  export interface OpenClawFileContent {
143
143
  type: 'file';
144
- fileId: string;
144
+ fileId?: string;
145
+ file?: string;
146
+ url?: string;
145
147
  fileSize?: number;
146
148
  }
147
149
  export type OpenClawMessage = OpenClawTextContent | OpenClawAtContent | OpenClawImageContent | OpenClawReplyContent | OpenClawAudioContent | OpenClawJsonContent | OpenClawFileContent;
@@ -46,6 +46,9 @@ export declare function chunk<T>(array: T[], size: number): T[][];
46
46
  * Get a human-readable message for a WebSocket close code
47
47
  */
48
48
  export declare function getCloseCodeMessage(code: number): string;
49
+ export type FileCategory = 'image' | 'audio' | 'file';
50
+ export declare function getFileType(pathOrUrl: string): FileCategory;
51
+ export declare function getFileName(pathOrUrl: string): string;
49
52
  export { CQCodeUtils, CQNode } from './cqcode.js';
50
53
  export { Logger } from './log.js';
51
54
  export { MarkdownToText, markdownToText, } from './markdown.js';
@@ -236,6 +236,65 @@ export function getCloseCodeMessage(code) {
236
236
  };
237
237
  return messages[code] ?? `Unknown close code: ${code}`;
238
238
  }
239
+ const IMAGE_EXTENSIONS = new Set([
240
+ 'jpg', 'jpeg', 'png', 'gif', 'bmp',
241
+ 'webp', 'svg', 'tiff', 'ico', 'heic'
242
+ ]);
243
+ const AUDIO_EXTENSIONS = new Set([
244
+ 'mp3', // 最通用
245
+ 'wav', // 无损/未压缩
246
+ 'ogg', // 开源/Web常用
247
+ 'm4a', // Apple/MPEG-4 音频
248
+ 'aac', // 高级音频编码
249
+ 'flac', // 无损压缩
250
+ 'wma', // Windows Media
251
+ 'aiff', // Apple Interchange
252
+ 'amr', // 移动端录音
253
+ 'opus' // 现代Web流媒体
254
+ ]);
255
+ export function getFileType(pathOrUrl) {
256
+ if (!pathOrUrl)
257
+ return 'file';
258
+ try {
259
+ const cleanPath = pathOrUrl.split(/[?#]/)[0];
260
+ const lastDotIndex = cleanPath.lastIndexOf('.');
261
+ if (lastDotIndex === -1) {
262
+ return 'file';
263
+ }
264
+ const extension = cleanPath.substring(lastDotIndex + 1).toLowerCase();
265
+ if (IMAGE_EXTENSIONS.has(extension)) {
266
+ return 'image';
267
+ }
268
+ if (AUDIO_EXTENSIONS.has(extension)) {
269
+ return 'audio';
270
+ }
271
+ return 'file';
272
+ }
273
+ catch (error) {
274
+ return 'file';
275
+ }
276
+ }
277
+ export function getFileName(pathOrUrl) {
278
+ if (!pathOrUrl)
279
+ return '';
280
+ try {
281
+ let cleanPath = pathOrUrl.split(/[?#]/)[0];
282
+ try {
283
+ cleanPath = decodeURIComponent(cleanPath);
284
+ }
285
+ catch (e) {
286
+ }
287
+ cleanPath = cleanPath.replace(/\\/g, '/');
288
+ if (cleanPath.endsWith('/')) {
289
+ cleanPath = cleanPath.slice(0, -1);
290
+ }
291
+ const fileName = cleanPath.split('/').pop();
292
+ return fileName || '';
293
+ }
294
+ catch (error) {
295
+ return '';
296
+ }
297
+ }
239
298
  // =============================================================================
240
299
  // CQ Code Utilities
241
300
  // =============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izhimu/qq",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A QQ channel plugin for OpenClaw using NapCat WebSocket",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",