@wu529778790/open-im 1.6.1-beta.13 → 1.6.1-beta.14

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.
@@ -17,7 +17,7 @@ export const CHANNEL_CAPABILITIES = {
17
17
  },
18
18
  qq: {
19
19
  inbound: { text: "native", image: "fallback", file: "fallback", voice: "fallback", video: "fallback" },
20
- outbound: { streamEdit: "native", streamPush: "fallback", image: "fallback", card: "fallback", typing: "fallback" },
20
+ outbound: { streamEdit: "none", streamPush: "none", image: "fallback", card: "fallback", typing: "fallback" },
21
21
  },
22
22
  wechat: {
23
23
  inbound: { text: "native", image: "fallback", file: "fallback", voice: "fallback", video: "fallback" },
@@ -8,6 +8,8 @@ describe("channel capabilities", () => {
8
8
  expect(CHANNEL_CAPABILITIES.qq.inbound.image).toBe("fallback");
9
9
  expect(CHANNEL_CAPABILITIES.qq.inbound.voice).toBe("fallback");
10
10
  expect(CHANNEL_CAPABILITIES.qq.inbound.video).toBe("fallback");
11
+ expect(CHANNEL_CAPABILITIES.qq.outbound.streamEdit).toBe("none");
12
+ expect(CHANNEL_CAPABILITIES.qq.outbound.streamPush).toBe("none");
11
13
  expect(CHANNEL_CAPABILITIES.wechat.inbound.image).toBe("fallback");
12
14
  expect(CHANNEL_CAPABILITIES.wework.inbound.video).toBe("fallback");
13
15
  expect(CHANNEL_CAPABILITIES.wework.outbound.image).toBe("native");
@@ -1,8 +1,8 @@
1
1
  export declare function sendTextReply(chatId: string, text: string): Promise<void>;
2
2
  export declare function sendImageReply(chatId: string, imagePath: string): Promise<void>;
3
3
  export declare function sendThinkingMessage(chatId: string, replyToMessageId?: string, _toolId?: string): Promise<string>;
4
- export declare function updateMessage(chatId: string, messageId: string, content: string, status: "thinking" | "streaming" | "done" | "error", note?: string, toolId?: string): Promise<void>;
5
- export declare function sendFinalMessages(chatId: string, messageId: string, fullContent: string, note: string, toolId?: string): Promise<void>;
4
+ export declare function updateMessage(_chatId: string, _messageId: string, _content: string, _status: "thinking" | "streaming" | "done" | "error", _note?: string, _toolId?: string): Promise<void>;
5
+ export declare function sendFinalMessages(chatId: string, messageId: string, fullContent: string, _note: string, toolId?: string): Promise<void>;
6
6
  export declare function sendErrorMessage(chatId: string, messageId: string, error: string, toolId?: string): Promise<void>;
7
7
  export declare function sendDirectorySelection(chatId: string, currentDir: string): Promise<void>;
8
8
  export declare function sendModeKeyboard(chatId: string, _userId: string, currentMode: string): Promise<void>;
@@ -3,12 +3,10 @@ import { splitLongContent } from "../shared/utils.js";
3
3
  import { buildImageFallbackMessage } from "../channels/capabilities.js";
4
4
  import { getQQBot } from "./client.js";
5
5
  import { buildMessageTitle, OPEN_IM_SYSTEM_TITLE } from "../shared/message-title.js";
6
- import { buildTextNote } from "../shared/message-note.js";
7
6
  import { buildDirectoryMessage, buildModeMessage } from "../shared/system-messages.js";
8
7
  const log = createLogger("QQSender");
9
8
  const MAX_QQ_MESSAGE_LENGTH = 1500;
10
- const STREAM_CHUNK_LENGTH = 1200;
11
- const streamStates = new Map();
9
+ const pendingReplies = new Map();
12
10
  function parseChatTarget(chatId) {
13
11
  if (chatId.startsWith("group:")) {
14
12
  return { kind: "group", id: chatId.slice("group:".length) };
@@ -32,81 +30,6 @@ async function sendRaw(chatId, text, replyToMessageId) {
32
30
  }
33
31
  return bot.sendPrivateMessage(target.id, text, replyToMessageId);
34
32
  }
35
- function getOrCreateStreamState(messageId, chatId, replyToMessageId) {
36
- const existing = streamStates.get(messageId);
37
- if (existing)
38
- return existing;
39
- const state = {
40
- chatId,
41
- replyToMessageId,
42
- lastSentLength: 0,
43
- sentStreamChunk: false,
44
- pendingText: "",
45
- };
46
- streamStates.set(messageId, state);
47
- return state;
48
- }
49
- function buildStreamChunk(toolId, content, note, withHeader = false) {
50
- const header = withHeader ? buildMessageTitle(toolId, "streaming") : "";
51
- const noteBlock = note ? `\n\n${buildTextNote(note)}` : "";
52
- return `${header}${header ? "\n" : ""}${content}${noteBlock}`.trim();
53
- }
54
- function findPreferredSplit(text, limit) {
55
- const normalizedLimit = Math.min(text.length, limit);
56
- const boundaries = ["\n\n", "\n", "。", ",", ";", ". ", "! ", "? ", ", ", " "];
57
- const minimumUsefulSplit = Math.min(80, Math.floor(normalizedLimit / 3));
58
- for (const boundary of boundaries) {
59
- const index = text.lastIndexOf(boundary, normalizedLimit);
60
- if (index >= minimumUsefulSplit) {
61
- return index + boundary.length;
62
- }
63
- }
64
- if (text.length >= minimumUsefulSplit) {
65
- return normalizedLimit;
66
- }
67
- return 0;
68
- }
69
- function resetStreamState(state) {
70
- state.lastSentLength = 0;
71
- state.lastToolNote = undefined;
72
- state.pendingText = "";
73
- state.sentStreamChunk = false;
74
- }
75
- async function sendIncrementalContent(state, toolId, content, note, flushAll = false) {
76
- if (!flushAll && content.length < state.lastSentLength) {
77
- resetStreamState(state);
78
- }
79
- const delta = content.slice(state.lastSentLength);
80
- const hasNewNote = !!note && note !== state.lastToolNote;
81
- if (!delta && !hasNewNote)
82
- return;
83
- if (delta) {
84
- state.pendingText += delta;
85
- let noteSent = false;
86
- while (state.pendingText.length > 0) {
87
- const splitAt = flushAll
88
- ? Math.min(state.pendingText.length, STREAM_CHUNK_LENGTH)
89
- : findPreferredSplit(state.pendingText, STREAM_CHUNK_LENGTH);
90
- if (splitAt <= 0)
91
- break;
92
- const part = state.pendingText.slice(0, splitAt).trim();
93
- state.pendingText = state.pendingText.slice(splitAt).trimStart();
94
- if (!part)
95
- continue;
96
- const text = buildStreamChunk(toolId, part, state.pendingText.length === 0 && hasNewNote ? note : undefined, !state.sentStreamChunk);
97
- await sendRaw(state.chatId, text, state.replyToMessageId);
98
- if (state.pendingText.length === 0 && hasNewNote)
99
- noteSent = true;
100
- state.sentStreamChunk = true;
101
- }
102
- state.lastSentLength = content.length;
103
- if (noteSent)
104
- state.lastToolNote = note;
105
- return;
106
- }
107
- await sendRaw(state.chatId, `[${toolId}] ${note}`, state.replyToMessageId);
108
- state.lastToolNote = note;
109
- }
110
33
  export async function sendTextReply(chatId, text) {
111
34
  try {
112
35
  const formatted = `${buildMessageTitle(OPEN_IM_SYSTEM_TITLE, "done")}\n\n${text}`;
@@ -122,36 +45,24 @@ export async function sendImageReply(chatId, imagePath) {
122
45
  await sendTextReply(chatId, buildImageFallbackMessage("qq", imagePath));
123
46
  }
124
47
  export async function sendThinkingMessage(chatId, replyToMessageId, _toolId = "claude") {
125
- const messageId = `${Date.now()}`;
126
- getOrCreateStreamState(messageId, chatId, replyToMessageId);
48
+ const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
49
+ pendingReplies.set(messageId, { replyToMessageId });
127
50
  return messageId;
128
51
  }
129
- export async function updateMessage(chatId, messageId, content, status, note, toolId = "claude") {
130
- if (status !== "streaming" && status !== "thinking")
131
- return;
132
- const state = getOrCreateStreamState(messageId, chatId);
133
- await sendIncrementalContent(state, toolId, content, note);
52
+ export async function updateMessage(_chatId, _messageId, _content, _status, _note, _toolId = "claude") {
53
+ // QQ 官方机器人接口不支持单条消息流式更新,这里显式忽略中间增量,只发送最终结果。
134
54
  }
135
- export async function sendFinalMessages(chatId, messageId, fullContent, note, toolId = "claude") {
136
- const state = getOrCreateStreamState(messageId, chatId);
137
- if (state.sentStreamChunk && fullContent.length === state.lastSentLength) {
138
- streamStates.delete(messageId);
139
- return;
140
- }
141
- await sendIncrementalContent(state, toolId, fullContent, note || undefined, true);
142
- if (!state.sentStreamChunk) {
143
- const completionText = note
144
- ? buildStreamChunk(toolId, fullContent, note, true)
145
- : `${buildMessageTitle(toolId, "done")}\n${fullContent}`;
146
- for (const part of splitLongContent(completionText, MAX_QQ_MESSAGE_LENGTH)) {
147
- await sendRaw(chatId, part, state.replyToMessageId);
148
- }
55
+ export async function sendFinalMessages(chatId, messageId, fullContent, _note, toolId = "claude") {
56
+ const replyToMessageId = pendingReplies.get(messageId)?.replyToMessageId;
57
+ pendingReplies.delete(messageId);
58
+ const completionText = `${buildMessageTitle(toolId, "done")}\n${fullContent}`;
59
+ for (const part of splitLongContent(completionText, MAX_QQ_MESSAGE_LENGTH)) {
60
+ await sendRaw(chatId, part, replyToMessageId);
149
61
  }
150
- streamStates.delete(messageId);
151
62
  }
152
63
  export async function sendErrorMessage(chatId, messageId, error, toolId = "claude") {
153
- const replyToMessageId = streamStates.get(messageId)?.replyToMessageId;
154
- streamStates.delete(messageId);
64
+ const replyToMessageId = pendingReplies.get(messageId)?.replyToMessageId;
65
+ pendingReplies.delete(messageId);
155
66
  await sendRaw(chatId, `${buildMessageTitle(toolId, "error")}\n${error}`, replyToMessageId);
156
67
  }
157
68
  export async function sendDirectorySelection(chatId, currentDir) {
@@ -1,4 +1,4 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
  const sendPrivateMessageMock = vi.fn();
3
3
  const sendGroupMessageMock = vi.fn();
4
4
  const sendChannelMessageMock = vi.fn();
@@ -11,10 +11,17 @@ vi.mock("./client.js", () => ({
11
11
  }));
12
12
  describe("QQ message sender", () => {
13
13
  beforeEach(() => {
14
+ vi.useFakeTimers();
14
15
  vi.resetModules();
15
16
  sendPrivateMessageMock.mockReset();
16
17
  sendGroupMessageMock.mockReset();
17
18
  sendChannelMessageMock.mockReset();
19
+ sendPrivateMessageMock.mockResolvedValue(undefined);
20
+ sendGroupMessageMock.mockResolvedValue(undefined);
21
+ sendChannelMessageMock.mockResolvedValue(undefined);
22
+ });
23
+ afterEach(() => {
24
+ vi.useRealTimers();
18
25
  });
19
26
  it("routes image replies through the fallback text sender", async () => {
20
27
  const sender = await import("./message-sender.js");
@@ -24,21 +31,16 @@ describe("QQ message sender", () => {
24
31
  expect(sendGroupMessageMock.mock.calls[0][1]).toContain("open-im");
25
32
  expect(sendGroupMessageMock.mock.calls[0][1]).toContain("C:\\images\\out.png");
26
33
  });
27
- it("does not send a second completion message after streaming the full reply", async () => {
34
+ it("ignores intermediate stream updates and sends only the final reply", async () => {
28
35
  const sender = await import("./message-sender.js");
29
36
  const messageId = await sender.sendThinkingMessage("private:user-1", "reply-1", "codex");
30
- await sender.updateMessage("private:user-1", messageId, "第一段回复", "streaming", undefined, "codex");
31
- await sender.sendFinalMessages("private:user-1", messageId, "第一段回复", "耗时 1.2s", "codex");
37
+ await sender.updateMessage("private:user-1", messageId, "第一段", "streaming", undefined, "codex");
38
+ await sender.updateMessage("private:user-1", messageId, "第一段\n第二段", "streaming", "耗时 1.2s", "codex");
39
+ await sender.sendFinalMessages("private:user-1", messageId, "最终答案", "耗时 1.2s", "codex");
32
40
  expect(sendPrivateMessageMock).toHaveBeenCalledTimes(1);
33
- expect(sendPrivateMessageMock.mock.calls[0][1]).toContain("第一段回复");
34
- expect(sendPrivateMessageMock.mock.calls[0][1]).not.toContain("耗时 1.2s");
35
- });
36
- it("resets the stream when content switches from a longer draft to a shorter answer", async () => {
37
- const sender = await import("./message-sender.js");
38
- const messageId = await sender.sendThinkingMessage("private:user-1", undefined, "codex");
39
- await sender.updateMessage("private:user-1", messageId, "这是比较长的前置内容,用来模拟思考流。", "streaming", undefined, "codex");
40
- await sender.updateMessage("private:user-1", messageId, "短答案", "streaming", undefined, "codex");
41
- expect(sendPrivateMessageMock).toHaveBeenCalledTimes(2);
42
- expect(sendPrivateMessageMock.mock.calls[1][1]).toContain("短答案");
41
+ expect(sendPrivateMessageMock.mock.calls[0][0]).toBe("user-1");
42
+ expect(sendPrivateMessageMock.mock.calls[0][1]).toContain("最终答案");
43
+ expect(sendPrivateMessageMock.mock.calls[0][1]).not.toContain("第一段");
44
+ expect(sendPrivateMessageMock.mock.calls[0][2]).toBe("reply-1");
43
45
  });
44
46
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.6.1-beta.13",
3
+ "version": "1.6.1-beta.14",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, Cursor)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",