@core-workspace/infoflow-openclaw-plugin 2026.3.8 → 2026.3.31

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.
@@ -12,8 +12,8 @@
12
12
  * SDK event system (@core-workspace/infoflow-sdk-nodejs):
13
13
  * - Group messages → "group.*" (covers group.text, group.mixed, group.image, etc.)
14
14
  * - Private messages → "private.*" (covers private.text, private.image, etc.)
15
- * - event.data is normalized by SDK: { chatType, msgType, fromUserId, groupId, body,
16
- * content, originalMessage, ... }
15
+ * - SDK 0.1.1: event.data is normalized with fromUserId, groupId, body, originalMessage, ...
16
+ * - SDK 0.1.5+: event.data is { chatType, msgType, raw } — raw is the full original payload
17
17
  *
18
18
  * SDK Bug Workaround:
19
19
  * SDK's normalizePrivateMessage() drops PicUrl/MsgId fields from private messages.
@@ -28,7 +28,8 @@ import { WSClient } from "@core-workspace/infoflow-sdk-nodejs";
28
28
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
29
29
  import { handleGroupChatMessage, handlePrivateChatMessage } from "../../handler/message-handler.js";
30
30
  import { isDuplicateMessage } from "./webhook-parser.js";
31
- import { formatInfoflowError, logVerbose } from "../../logging.js";
31
+ import { formatInfoflowError, logVerbose, getInfoflowWebhookLog } from "../../logging.js";
32
+ import { extractIdFromRawJson } from "../../channel/outbound.js";
32
33
  import type { ResolvedInfoflowAccount } from "../../types.js";
33
34
 
34
35
  // ---------------------------------------------------------------------------
@@ -79,6 +80,22 @@ export class InfoflowWSReceiver {
79
80
  return normalized;
80
81
  };
81
82
  }
83
+
84
+ // Patch frameCodec.parsePayload to attach _rawJson to parsed objects.
85
+ // This preserves large integer precision (e.g. messageid) that JSON.parse loses.
86
+ const frameCodec = (this.wsClient as any).frameCodec;
87
+ if (frameCodec && typeof frameCodec.parsePayload === "function") {
88
+ const originalParse = frameCodec.parsePayload.bind(frameCodec);
89
+ frameCodec.parsePayload = function (frame: any) {
90
+ const result = originalParse(frame);
91
+ if (result && typeof result === "object" && frame.payload) {
92
+ try {
93
+ result._rawJson = frame.payload.toString("utf-8");
94
+ } catch { /* ignore */ }
95
+ }
96
+ return result;
97
+ };
98
+ }
82
99
  }
83
100
 
84
101
  /** Connect and start receiving messages. */
@@ -106,7 +123,10 @@ export class InfoflowWSReceiver {
106
123
  this.wsClient.on("private.*", handlePrivateEvent);
107
124
 
108
125
  // Connect (two-phase: endpoint allocation → WS handshake)
126
+ const wsGateway = this.options.account.config.wsGateway ?? "infoflow-open-gateway.weiyun.baidu.com";
127
+ getInfoflowWebhookLog().info(`[ws:connect] connecting to ${wsGateway}`);
109
128
  await this.wsClient.connect();
129
+ getInfoflowWebhookLog().info(`[ws:connect] connected to ${wsGateway}`);
110
130
 
111
131
  // Listen for abortSignal to gracefully disconnect
112
132
  this.options.abortSignal.addEventListener(
@@ -122,6 +142,7 @@ export class InfoflowWSReceiver {
122
142
  stop(): void {
123
143
  if (this.stopped) return;
124
144
  this.stopped = true;
145
+ getInfoflowWebhookLog().info(`[ws:disconnect] stopping ws receiver`);
125
146
  try {
126
147
  this.wsClient.disconnect();
127
148
  } catch {
@@ -131,8 +152,10 @@ export class InfoflowWSReceiver {
131
152
 
132
153
  /**
133
154
  * Handle a group message event from the new SDK.
134
- * SDK normalizes to: { chatType, msgType, fromUserId, groupId, body, content,
135
- * originalMessage, createTime, ... }
155
+ *
156
+ * SDK 0.1.1: normalizes to { chatType, msgType, fromUserId, groupId, body, originalMessage, ... }
157
+ * SDK 0.1.5: normalizes to { chatType, msgType, raw } — raw is the original payload
158
+ *
136
159
  * We reconstruct the raw msgData shape expected by handleGroupChatMessage.
137
160
  */
138
161
  private async handleGroupEvent(data: any): Promise<void> {
@@ -140,37 +163,54 @@ export class InfoflowWSReceiver {
140
163
 
141
164
  this.options.statusSink?.({ lastInboundAt: Date.now() });
142
165
 
166
+ // SDK 0.1.5 puts everything in data.raw; 0.1.1 inlines fields directly.
167
+ const payload = data.raw ?? data;
168
+
143
169
  // Reconstruct raw msgData compatible with handleGroupChatMessage expectations:
144
170
  // { message: { header: { fromuserid, servertime, messageid, ... }, body }, groupid, ... }
145
- const originalMessage = data.originalMessage ?? {};
146
- const header = originalMessage.header ?? {};
171
+ const originalMessage = payload.originalMessage ?? payload;
172
+ const message = payload.message ?? originalMessage.message ?? {};
173
+ const header = message.header ?? originalMessage.header ?? {};
174
+
175
+ // Extract messageid with full precision from raw JSON string (avoids JS Number precision loss
176
+ // for large int64 IDs like 1859821262633816373 which JSON.parse rounds to ...300).
177
+ const rawJson: string | undefined = payload._rawJson;
178
+ const preciseMessageId =
179
+ (rawJson && extractIdFromRawJson(rawJson, "messageid")) ??
180
+ (header.messageid != null ? String(header.messageid) : undefined);
181
+ const preciseClientMsgId =
182
+ (rawJson && extractIdFromRawJson(rawJson, "clientmsgid")) ??
183
+ (header.clientmsgid != null ? String(header.clientmsgid) : undefined);
184
+
147
185
  const msgData: Record<string, unknown> = {
148
186
  eventtype: "MESSAGE_RECEIVE",
149
- groupid: data.groupId ?? data.groupid,
187
+ groupid: payload.groupid ?? payload.groupId ?? data.groupId,
188
+ fromid: payload.fromid,
150
189
  message: {
151
190
  header: {
152
- fromuserid: data.fromUserId ?? header.fromuserid ?? "",
153
- toid: data.groupId ?? data.groupid,
191
+ fromuserid: header.fromuserid ?? payload.fromUserId ?? data.fromUserId ?? "",
192
+ toid: payload.groupid ?? payload.groupId ?? data.groupId,
154
193
  totype: "GROUP",
155
- msgtype: data.msgType ?? header.msgtype ?? "text",
156
- messageid: header.messageid ?? header.clientmsgid,
157
- clientmsgid: header.clientmsgid,
194
+ msgtype: header.msgtype ?? data.msgType ?? "text",
195
+ messageid: preciseMessageId,
196
+ clientmsgid: preciseClientMsgId,
158
197
  servertime: header.servertime,
159
198
  clienttime: header.clienttime,
160
199
  at: header.at ?? { atrobotids: [] },
161
200
  },
162
- body: data.body ?? (data.content ? [{ type: "TEXT", content: data.content }] : []),
201
+ body: message.body ?? payload.body ?? data.body ??
202
+ (data.content ? [{ type: "TEXT", content: data.content }] : []),
163
203
  },
164
204
  };
165
205
 
166
- // Dedup check using clientmsgid or messageid as key
167
- const dedupKey = header.clientmsgid ?? header.messageid;
206
+ // Dedup check using clientmsgid or messageid as key (use precise string IDs)
207
+ const dedupKey = preciseClientMsgId ?? preciseMessageId;
168
208
  if (dedupKey && isDuplicateMessage({ CreateTime: String(dedupKey) })) {
169
209
  logVerbose("[infoflow:ws] duplicate group message, skipping");
170
210
  return;
171
211
  }
172
212
 
173
- logVerbose(`[infoflow:ws] group message: from=${data.fromUserId}, msgType=${data.msgType}, groupId=${data.groupId}`);
213
+ logVerbose(`[infoflow:ws] group message: from=${header.fromuserid}, msgType=${header.msgtype}, groupId=${payload.groupid ?? payload.groupId}`);
174
214
 
175
215
  await handleGroupChatMessage({
176
216
  cfg: this.options.config,
@@ -182,7 +222,10 @@ export class InfoflowWSReceiver {
182
222
 
183
223
  /**
184
224
  * Handle a private message event from the new SDK.
185
- * SDK normalizes to: { chatType, msgType, fromUserId, content, createTime, ... }
225
+ *
226
+ * SDK 0.1.1: normalizes to { chatType, msgType, fromUserId, content, createTime, originalMessage, ... }
227
+ * SDK 0.1.5: normalizes to { chatType, msgType, raw } — raw is the original payload
228
+ *
186
229
  * We reconstruct the raw msgData shape expected by handlePrivateChatMessage.
187
230
  */
188
231
  private async handlePrivateEvent(data: any): Promise<void> {
@@ -190,20 +233,21 @@ export class InfoflowWSReceiver {
190
233
 
191
234
  this.options.statusSink?.({ lastInboundAt: Date.now() });
192
235
 
193
- logVerbose(`[DEBUG ws.private] SDK event data 完整结构: ${JSON.stringify(data, null, 2)}`);
236
+ // SDK 0.1.5 puts everything in data.raw; 0.1.1 inlines fields directly.
237
+ const payload = data.raw ?? data;
194
238
 
195
239
  // Reconstruct raw msgData compatible with handlePrivateChatMessage expectations:
196
240
  // { FromUserId, Content, MsgType, CreateTime, PicUrl, MsgId, ... }
197
- const originalMessage = data.originalMessage ?? {};
241
+ const originalMessage = payload.originalMessage ?? payload;
198
242
  const msgData: Record<string, unknown> = {
199
- FromUserId: data.fromUserId ?? data.FromUserId ?? originalMessage.FromUserId ?? "",
200
- FromUserName: data.fromUserName ?? data.FromUserName ?? originalMessage.FromUserName,
201
- Content: data.content ?? data.Content ?? originalMessage.Content ?? "",
202
- MsgType: data.msgType ?? data.MsgType ?? originalMessage.MsgType ?? "text",
203
- CreateTime: data.createTime ?? data.CreateTime ?? originalMessage.CreateTime ?? String(Date.now()),
243
+ FromUserId: payload.FromUserId ?? payload.fromUserId ?? data.fromUserId ?? originalMessage.FromUserId ?? "",
244
+ FromUserName: payload.FromUserName ?? payload.fromUserName ?? data.fromUserName ?? originalMessage.FromUserName,
245
+ Content: payload.Content ?? payload.content ?? data.content ?? originalMessage.Content ?? "",
246
+ MsgType: payload.MsgType ?? payload.msgType ?? data.msgType ?? originalMessage.MsgType ?? "text",
247
+ CreateTime: payload.CreateTime ?? payload.createTime ?? data.createTime ?? originalMessage.CreateTime ?? String(Date.now()),
204
248
  // 图片消息字段
205
- PicUrl: data.picUrl ?? data.PicUrl ?? originalMessage.PicUrl ?? "",
206
- MsgId: data.msgId ?? data.MsgId ?? originalMessage.MsgId,
249
+ PicUrl: payload.PicUrl ?? payload.picUrl ?? data.picUrl ?? originalMessage.PicUrl ?? "",
250
+ MsgId: payload.MsgId ?? payload.msgId ?? data.msgId ?? originalMessage.MsgId,
207
251
  };
208
252
 
209
253
  // Dedup check
@@ -222,5 +266,3 @@ export class InfoflowWSReceiver {
222
266
  });
223
267
  }
224
268
  }
225
-
226
-
@@ -45,6 +45,11 @@ export type CreateInfoflowReplyDispatcherParams = {
45
45
  * Default: "text"
46
46
  */
47
47
  messageFormat?: "text" | "markdown";
48
+ /**
49
+ * Maximum character limit per outbound message chunk.
50
+ * Default: 1800
51
+ */
52
+ textChunkLimit?: number;
48
53
  };
49
54
 
50
55
  /**
@@ -64,6 +69,7 @@ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatc
64
69
  replyToPreview,
65
70
  replyToImid,
66
71
  messageFormat = "text",
72
+ textChunkLimit = 1800,
67
73
  } = params;
68
74
  const core = getInfoflowRuntime();
69
75
 
@@ -93,8 +99,6 @@ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatc
93
99
 
94
100
  // Build replyTo context (only used for the first outbound message)
95
101
  // Note: replyTo is suppressed when messageFormat is "markdown" (not supported by API)
96
- logVerbose(`[DEBUG reply-dispatcher] isGroup=${isGroup}, replyToMessageId=${replyToMessageId}, replyToImid=${replyToImid}, replyToPreview=${replyToPreview?.slice(0, 50)}`);
97
-
98
102
  const replyTo: InfoflowOutboundReply | undefined =
99
103
  isGroup && effectiveReplyToMessageId
100
104
  ? {
@@ -106,17 +110,8 @@ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatc
106
110
  : undefined;
107
111
  let replyApplied = false;
108
112
 
109
- // Debug: Log constructed replyTo
110
- logVerbose(`[DEBUG reply-dispatcher] replyTo constructed: ${JSON.stringify(replyTo)}`);
111
- if (replyTo) {
112
- logVerbose(`[DEBUG reply-dispatcher] Creating reply context with messageid=${replyTo.messageid}`);
113
- } else {
114
- logVerbose(`[DEBUG reply-dispatcher] replyTo is undefined - not creating reply context`);
115
- }
116
-
117
113
  const deliver = async (payload: ReplyPayload) => {
118
114
  const text = payload.text ?? "";
119
- logVerbose(`[infoflow] deliver called: to=${to}, text=${text}`);
120
115
 
121
116
  // Normalize media URL list (same pattern as Feishu reply-dispatcher)
122
117
  const mediaList =
@@ -126,7 +121,6 @@ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatc
126
121
  ? [payload.mediaUrl]
127
122
  : [];
128
123
 
129
- logVerbose(`[infoflow] deliver called: to=${to}, text=${text}, mediaList=${JSON.stringify(mediaList)}`);
130
124
  if (!text.trim() && mediaList.length === 0) {
131
125
  return;
132
126
  }
@@ -169,8 +163,8 @@ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatc
169
163
  // AT nodes will be added to contents for notification, but text stays clean
170
164
  let messageText = text;
171
165
 
172
- // Chunk text to 4000 chars max (Infoflow limit)
173
- const chunks = core.channel.text.chunkText(messageText, 4000);
166
+ // Chunk text using markdown-aware chunker with configured limit
167
+ const chunks = core.channel.text.chunkMarkdownText(messageText, textChunkLimit);
174
168
  // Only include @mentions in the first chunk (avoid duplicate @s)
175
169
  let isFirstChunk = true;
176
170
 
@@ -196,10 +190,6 @@ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatc
196
190
  // Only include replyTo on the first outbound message
197
191
  const chunkReplyTo = !replyApplied ? replyTo : undefined;
198
192
 
199
- // Debug: Log when sending with replyTo
200
- logVerbose(`[DEBUG deliver] chunkReplyTo: ${JSON.stringify(chunkReplyTo)}`);
201
- logVerbose(`[DEBUG deliver] replyApplied=${replyApplied}, replyTo exists=${!!replyTo}`);
202
-
203
193
  const result = await sendInfoflowMessage({
204
194
  cfg,
205
195
  to,
@@ -210,10 +200,13 @@ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatc
210
200
  if (chunkReplyTo) replyApplied = true;
211
201
 
212
202
  if (result.ok) {
203
+ getInfoflowSendLog().info(
204
+ `[outbound] to=${to}, len=${chunk.length}, msgId=${result.messageId ?? "N/A"}, replyTo=${chunkReplyTo?.messageid ?? "none"}`,
205
+ );
213
206
  statusSink?.({ lastOutboundAt: Date.now() });
214
207
  } else if (result.error) {
215
208
  getInfoflowSendLog().error(
216
- `[infoflow] reply failed to=${to}, accountId=${accountId}: ${result.error}`,
209
+ `[outbound:error] to=${to}, accountId=${accountId}: ${result.error}`,
217
210
  );
218
211
  }
219
212
  }
@@ -77,10 +77,16 @@ function mergeInfoflowAccountConfig(
77
77
  appAgentId?: number;
78
78
  dmMessageFormat?: "text" | "markdown";
79
79
  groupMessageFormat?: "text" | "markdown";
80
- dmPolicy?: string;
80
+ replyMode?: import("../types.js").InfoflowReplyMode;
81
+ groupSessionMode?: import("../types.js").InfoflowGroupSessionMode;
82
+ followUp?: boolean;
83
+ followUpWindow?: number;
84
+ groups?: Record<string, import("../types.js").InfoflowGroupConfig>;
85
+ dmPolicy?: import("../types.js").InfoflowDmPolicy;
81
86
  allowFrom?: string[];
82
- groupPolicy?: string;
87
+ groupPolicy?: import("../types.js").InfoflowGroupPolicy;
83
88
  groupAllowFrom?: string[];
89
+ textChunkLimit?: number;
84
90
  } {
85
91
  const raw = getChannelSection(cfg) ?? {};
86
92
  const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
@@ -102,9 +108,14 @@ function mergeInfoflowAccountConfig(
102
108
  appAgentId?: number;
103
109
  dmMessageFormat?: "text" | "markdown";
104
110
  groupMessageFormat?: "text" | "markdown";
105
- dmPolicy?: string;
111
+ replyMode?: import("../types.js").InfoflowReplyMode;
112
+ groupSessionMode?: import("../types.js").InfoflowGroupSessionMode;
113
+ followUp?: boolean;
114
+ followUpWindow?: number;
115
+ groups?: Record<string, import("../types.js").InfoflowGroupConfig>;
116
+ dmPolicy?: import("../types.js").InfoflowDmPolicy;
106
117
  allowFrom?: string[];
107
- groupPolicy?: string;
118
+ groupPolicy?: import("../types.js").InfoflowGroupPolicy;
108
119
  groupAllowFrom?: string[];
109
120
  };
110
121
  }
@@ -125,13 +136,16 @@ export function resolveInfoflowAccount(params: {
125
136
  const merged = mergeInfoflowAccountConfig(params.cfg, accountId);
126
137
  const accountEnabled = merged.enabled !== false;
127
138
  const enabled = baseEnabled && accountEnabled;
128
- const apiHost = merged.apiHost ?? "";
139
+ const apiHost = merged.apiHost ?? "http://apiin.im.baidu.com";
129
140
  const checkToken = merged.checkToken ?? "";
130
141
  const encodingAESKey = merged.encodingAESKey ?? "";
131
142
  const appKey = merged.appKey ?? "";
132
143
  const appSecret = merged.appSecret ?? "";
144
+ const effectiveConnectionMode = merged.connectionMode ?? "websocket";
133
145
  const configured =
134
- Boolean(checkToken) && Boolean(encodingAESKey) && Boolean(appKey) && Boolean(appSecret);
146
+ effectiveConnectionMode === "websocket"
147
+ ? Boolean(appKey) && Boolean(appSecret)
148
+ : Boolean(checkToken) && Boolean(encodingAESKey) && Boolean(appKey) && Boolean(appSecret);
135
149
 
136
150
  return {
137
151
  accountId,
@@ -155,10 +169,16 @@ export function resolveInfoflowAccount(params: {
155
169
  appAgentId: merged.appAgentId,
156
170
  dmMessageFormat: merged.dmMessageFormat,
157
171
  groupMessageFormat: merged.groupMessageFormat,
172
+ replyMode: merged.replyMode,
173
+ groupSessionMode: merged.groupSessionMode,
174
+ followUp: merged.followUp,
175
+ followUpWindow: merged.followUpWindow,
176
+ groups: merged.groups,
158
177
  dmPolicy: merged.dmPolicy,
159
178
  allowFrom: merged.allowFrom,
160
179
  groupPolicy: merged.groupPolicy,
161
180
  groupAllowFrom: merged.groupAllowFrom,
181
+ textChunkLimit: merged.textChunkLimit,
162
182
  },
163
183
  };
164
184
  }
@@ -205,10 +205,11 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
205
205
  },
206
206
  },
207
207
  outbound: {
208
- deliveryMode: "direct",
208
+ deliveryMode: "gateway",
209
209
  chunkerMode: "markdown",
210
- textChunkLimit: 4000,
211
- chunker: (text, limit) => getInfoflowRuntime().channel.text.chunkText(text, limit),
210
+ textChunkLimit: 1800,
211
+ resolveTextChunkLimit: (account) => account.config.textChunkLimit,
212
+ chunker: (text, limit) => getInfoflowRuntime().channel.text.chunkMarkdownText(text, limit),
212
213
  sendText: async ({ cfg, to, text, accountId }) => {
213
214
  logVerbose(`[infoflow:sendText] to=${to}, accountId=${accountId}`);
214
215
  // Use "markdown" type even though param is named `text`: LLM outputs are often markdown,
@@ -322,7 +323,7 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
322
323
  gateway: {
323
324
  startAccount: async (ctx) => {
324
325
  const account = ctx.account;
325
- const connectionMode = account.config.connectionMode ?? "webhook";
326
+ const connectionMode = account.config.connectionMode ?? "websocket";
326
327
  ctx.log?.info(`[${account.accountId}] starting Infoflow ${connectionMode}`);
327
328
  ctx.setStatus({
328
329
  accountId: account.accountId,
@@ -298,6 +298,14 @@ export async function sendInfoflowPrivateImage(params: {
298
298
  const data = JSON.parse(responseText) as Record<string, unknown>;
299
299
  logVerbose(`[infoflow:sendPrivateImage] response: status=${res.status}, data=${responseText}`);
300
300
 
301
+ // Check outer code first (same format as text message API)
302
+ const code = typeof data.code === "string" ? data.code : "";
303
+ if (code && code !== "ok") {
304
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code}`);
305
+ getInfoflowSendLog().error(`[infoflow:sendPrivateImage] failed: ${errMsg}`);
306
+ return { ok: false, error: errMsg };
307
+ }
308
+ // Also check inner errcode
301
309
  if (data.errcode && data.errcode !== 0) {
302
310
  const errMsg = String(data.errmsg ?? `errcode ${data.errcode}`);
303
311
  getInfoflowSendLog().error(`[infoflow:sendPrivateImage] failed: ${errMsg}`);
@@ -283,8 +283,9 @@ export async function sendInfoflowPrivateMessage(params: {
283
283
 
284
284
  const bodyStr = JSON.stringify(payload);
285
285
 
286
- // Log request URL and body when verbose logging is enabled
287
- logVerbose(`[infoflow:sendPrivate] POST body: ${bodyStr}`);
286
+ // Log request
287
+ getInfoflowSendLog().info(`[outbound:dm] to=${toUser}, msgtype=${payload.msgtype}, bodyLen=${bodyStr.length}`);
288
+ logVerbose(`[outbound:dm] POST body: ${bodyStr}`);
288
289
 
289
290
  const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_SEND_PATH}`, {
290
291
  method: "POST",
@@ -295,7 +296,8 @@ export async function sendInfoflowPrivateMessage(params: {
295
296
 
296
297
  const responseText = await res.text();
297
298
  const data = JSON.parse(responseText) as Record<string, unknown>;
298
- logVerbose(`[infoflow:sendPrivate] response: status=${res.status}, data=${responseText}`);
299
+ getInfoflowSendLog().info(`[outbound:dm] response: status=${res.status}, code=${data.code ?? "?"}, msgkey=${(data.data as any)?.msgkey ?? "?"}`);
300
+ logVerbose(`[outbound:dm] response body: ${responseText}`);
299
301
 
300
302
  // Check outer code first
301
303
  const code = typeof data.code === "string" ? data.code : "";
@@ -465,10 +467,10 @@ export async function sendInfoflowGroupMessage(params: {
465
467
  ...(replyTo
466
468
  ? {
467
469
  reply: {
468
- messageid: String(replyTo.messageid), // messageid应该是字符串
470
+ messageid: String(replyTo.messageid),
469
471
  preview: replyTo.preview ?? "",
470
- ...(replyTo.imid ? { imid: replyTo.imid } : {}), // 如果有 imid 则添加
471
- replyType: replyTo.replytype ?? "1", // 注意是replyType,不是replytype
472
+ ...(replyTo.imid ? { imid: replyTo.imid } : {}),
473
+ replytype: replyTo.replytype ?? "1",
472
474
  },
473
475
  }
474
476
  : {}),
@@ -478,6 +480,9 @@ export async function sendInfoflowGroupMessage(params: {
478
480
  // Build request body
479
481
  const bodyStr = JSON.stringify(payload);
480
482
 
483
+ getInfoflowSendLog().info(`[outbound:group] groupId=${groupId}, msgtype=${msgtype}, bodyLen=${bodyStr.length}, hasReply=${!!replyTo}`);
484
+ logVerbose(`[outbound:group] POST body: ${bodyStr}`);
485
+
481
486
  const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
482
487
  method: "POST",
483
488
  headers,
@@ -487,7 +492,8 @@ export async function sendInfoflowGroupMessage(params: {
487
492
 
488
493
  const responseText = await res.text();
489
494
  const data = JSON.parse(responseText) as Record<string, unknown>;
490
- logVerbose(`[infoflow:sendGroup] response: status=${res.status}, data=${responseText}`);
495
+ getInfoflowSendLog().info(`[outbound:group] response: status=${res.status}, code=${data.code ?? "?"}, messageid=${extractIdFromRawJson(responseText, "messageid") ?? "?"}`);
496
+ logVerbose(`[outbound:group] response body: ${responseText}`);
491
497
 
492
498
  const code = typeof data.code === "string" ? data.code : "";
493
499
  if (code !== "ok") {
@@ -861,6 +867,8 @@ export async function sendInfoflowMessage(params: {
861
867
  // Parse target: remove "infoflow:" prefix if present
862
868
  const target = to.replace(/^infoflow:/i, "");
863
869
 
870
+ getInfoflowSendLog().info(`[outbound] sendMessage: to=${target}, items=${resolvedContents.length}, types=[${resolvedContents.map(c => c.type).join(",")}]`);
871
+
864
872
  // Check if target is a group (format: group:123)
865
873
  const groupMatch = target.match(/^group:(\d+)/i);
866
874
  if (groupMatch) {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * /infoflow-changelog command
3
+ *
4
+ * 读取 CHANGELOG.md,展示最近几次更新内容。
5
+ *
6
+ * 用法:
7
+ * /infoflow-changelog — 最近 5 次更新
8
+ * /infoflow-changelog all — 全部更新记录
9
+ */
10
+
11
+ import { readFileSync, existsSync } from "node:fs";
12
+ import { resolve, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const CHANGELOG_PATH = resolve(__dirname, "../../CHANGELOG.md");
17
+ const DEFAULT_SHOW = 5;
18
+
19
+ export async function runChangelogCommand(ctx: {
20
+ args?: string;
21
+ }): Promise<{ text: string }> {
22
+ const showAll = ctx.args?.trim().toLowerCase() === "all";
23
+
24
+ if (!existsSync(CHANGELOG_PATH)) {
25
+ return { text: "**更新日志**\n\n暂无更新记录。" };
26
+ }
27
+
28
+ let content: string;
29
+ try {
30
+ content = readFileSync(CHANGELOG_PATH, "utf-8");
31
+ } catch (err: unknown) {
32
+ return {
33
+ text: `**更新日志**\n\n读取更新日志失败:${err instanceof Error ? err.message : String(err)}`,
34
+ };
35
+ }
36
+
37
+ // 按 "## " 版本标题分割,跳过第一个(文件标题行)
38
+ const sections = content.split(/^## /m).filter(Boolean);
39
+
40
+ if (sections.length === 0) {
41
+ return { text: "**Infoflow 更新日志**\n\n暂无更新记录。" };
42
+ }
43
+
44
+ const toShow = showAll ? sections : sections.slice(0, DEFAULT_SHOW);
45
+ const body = toShow.map((s) => `## ${s.trimEnd()}`).join("\n\n");
46
+
47
+ const hint =
48
+ !showAll && sections.length > DEFAULT_SHOW
49
+ ? `\n\n> 仅显示最近 ${toShow.length} 次更新,发送 \`/infoflow-changelog all\` 查看全部 ${sections.length} 次更新记录。`
50
+ : "";
51
+
52
+ return { text: `**Infoflow 更新日志**\n\n${body}${hint}` };
53
+ }