Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- **feishu**: coalesce consecutive image messages from the same session into a single multi-image dispatch to fix first-image drop on batch sends (#1395). When the Feishu mobile client sends N images in quick succession, each image arrives as a separate `image` event with very close `create_time` values. Dispatching each immediately caused core/engine's `create_time` watermark (PR #1168) to drop the oldest image, so the agent only saw N-1 images. A per-session image buffer with a 150ms quiet window now merges the burst into one `core.Message` carrying all images, in send order. Single-image sends and quoted-image replies are unaffected.
- **claudecode**: fix per-spawn system-prompt temp file EACCES under `run_as_user` (#1429). The per-spawn temp file written by `writeTempAppendPromptFile` (the 1% edge-case path used when the prompt has session-specific platform formatting or user `append_system_prompt`) inherited `os.CreateTemp`'s 0600 mode and was owned by the cc-connect process user (often root under systemd). When the agent was spawned under a different `run_as_user`, it could not read the file and exited before any prompt was loaded. The file is now `chmod 0o644` immediately after write, matching the shared `ensureSharedSystemPromptFile` path. Prompt content is non-secret (a superset of the already-shared base prompt), so 0644 is consistent with the shared file. Does not affect the shared-file path (already 0644 since #1376) or the daemon-mode path resolution (#1419).
- **core**: queue post-restart notification and dispatch on platform ready (#1383). Previously `/restart` sent the success notification immediately after engine startup, racing the platform's async connect window (Telegram: ~2.6s). On a not-yet-ready platform the send was silently dropped at debug log level. The notify is now queued on the engine and dispatched when the target platform reaches `OnPlatformReady`, with bounded retry (3 attempts, 0/500/1500 ms backoff) on transient send failure. Failed sends log at warn level. A 10s safety timeout drops the notify with a warning if the target platform never reaches ready, so startup is never blocked indefinitely. Also covers Discord / Weixin / Matrix (other AsyncRecoverablePlatform implementations) for free.
- **qq**: route long `Send()` messages through the OneBot HTTP API to avoid the 15s WebSocket timeout (#1474). Long replies (~2.5KB+) always tripped the hardcoded WS timeout in `callAPI` because the WS readLoop is shared between inbound events and outbound replies; long sends queue behind inbound traffic and stall. New `send_via_http` config knob (default `"auto"`): `"auto"` switches to HTTP when message exceeds 512 bytes and `http_url` is configured, `"always"` forces HTTP, `"never"` keeps WebSocket. Falls back to WS when `http_url` is empty so unconfigured deployments still deliver. `SendFile()` and `SendImage()` are unaffected (separate code paths).

## v1.3.3 (2026-06-15)

Expand Down
14 changes: 14 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1570,6 +1570,20 @@ app_secret = "your-feishu-app-secret"
# http_url = "" # optional: OneBot HTTP API URL for file operations, e.g. "http://127.0.0.1:3000"
# # Required for SendFile. Useful when OneBot runs in WSL/Docker.
# # 可选:OneBot HTTP API 地址,用于文件发送。OneBot 在 WSL/Docker 中时必须配置。
# send_via_http = "auto" # optional: how Send() dispatches text replies (default "auto")
# # "auto" → WS for short messages (<=512 bytes), HTTP for longer ones.
# # Long replies via WS can hit the 15s OneBot WS timeout (#1474).
# # HTTP has a 120s timeout and avoids WS readLoop queueing.
# # Falls back to WS if http_url is not configured.
# # "always" → always use HTTP (requires http_url); falls back to WS if absent.
# # "never" → always use WebSocket, even for long messages.
# # 可选:Send() 的派发方式(默认 "auto")
# # "auto" → 短消息(<=512 字节)走 WebSocket,长消息走 HTTP。
# # 长回复走 WS 可能命中 OneBot 15 秒 WS 超时(#1474)。
# # HTTP 有 120 秒超时,并避免阻塞 WS readLoop。
# # 未配置 http_url 时退回 WS。
# # "always" → 始终走 HTTP(需要 http_url);缺失时退回 WS。
# # "never" → 始终走 WebSocket,即使长消息。
# allow_from = "*" # allowed QQ user IDs, e.g. "12345,67890" or "*" for all
# # 允许的 QQ 号,如 "12345,67890","*" 表示所有
# share_session_in_channel = false # If true, all users in a group share one agent session / 群聊共享会话
Expand Down
63 changes: 59 additions & 4 deletions platform/qq/qq.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ func init() {
core.RegisterPlatform("qq", New)
}

// defaultHTTPFallbackThreshold is the message size (in bytes) above which Send()
// routes through the OneBot HTTP API instead of the WebSocket. The OneBot WS
// implementation round-trips every reply through the same readLoop that
// delivers events, so a long reply can race or stall and trip the 15s WS
// timeout (see #1474). HTTP send has a dedicated 120s timeout and avoids
// queueing on the WS readLoop.
const defaultHTTPFallbackThreshold = 512

// Platform connects to a OneBot v11 implementation (NapCat, LLOneBot, etc.)
// via forward WebSocket. It receives message events and sends messages back
// through the same WS connection.
Expand All @@ -40,7 +48,8 @@ type Platform struct {
selfID int64
dedup core.MessageDedup
groupNameCache sync.Map // groupID -> group name
httpURL string // OneBot HTTP API URL, e.g. "http://127.0.0.1:3000"
httpURL string // OneBot HTTP API URL, e.g. "http://127.0.0.1:3000"
sendViaHTTP string // "auto" (default) | "always" | "never"
}

func New(opts map[string]any) (core.Platform, error) {
Expand All @@ -57,12 +66,26 @@ func New(opts map[string]any) (core.Platform, error) {
httpURL, _ := opts["http_url"].(string)
httpURL = strings.TrimRight(httpURL, "/")

sendViaHTTP, _ := opts["send_via_http"].(string)
switch sendViaHTTP {
case "", "auto", "always", "never":
// valid
default:
slog.Warn("qq: invalid send_via_http value, falling back to auto",
"got", sendViaHTTP, "valid", []string{"auto", "always", "never"})
sendViaHTTP = "auto"
}
if sendViaHTTP == "" {
sendViaHTTP = "auto"
}

return &Platform{
wsURL: wsURL,
token: token,
allowFrom: allowFrom,
shareSessionInChannel: shareSessionInChannel,
httpURL: httpURL,
httpURL: httpURL,
sendViaHTTP: sendViaHTTP,
}, nil
}

Expand Down Expand Up @@ -443,6 +466,13 @@ func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) erro
}

// Send sends a message to the conversation identified by replyCtx.
//
// Long messages route through the OneBot HTTP API instead of WebSocket. The
// OneBot WS implementation round-trips every reply through the same readLoop
// that delivers inbound events, so a long reply can race or stall and trip
// the 15s WS timeout (#1474). HTTP send has a dedicated 120s timeout and
// avoids queueing on the WS readLoop. Threshold + mode controlled by
// `send_via_http` config (default "auto" → switch at 512 bytes).
func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error {
rctx, ok := replyCtx.(*replyContext)
if !ok {
Expand All @@ -453,17 +483,42 @@ func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error
"message": content,
}

useHTTP := p.shouldUseHTTP(content)
call := p.callAPI
if useHTTP {
call = p.callHTTPAPI
}

if rctx.messageType == "group" {
params["group_id"] = rctx.groupID
_, err := p.callAPI("send_group_msg", params)
_, err := call("send_group_msg", params)
return err
}

params["user_id"] = rctx.userID
_, err := p.callAPI("send_private_msg", params)
_, err := call("send_private_msg", params)
return err
}

// shouldUseHTTP returns true when Send() should dispatch through the OneBot
// HTTP API for this message. Honors the `send_via_http` config:
// - "always" → true (skip WS entirely for sends)
// - "never" → false (always use WS, even for long messages)
// - "auto" → true when len(content) > 512 bytes AND http_url is configured
//
// "auto" falls back to WS when http_url is empty — there is no HTTP path to
// use. SendFile() and SendImage() are unaffected (separate code paths).
func (p *Platform) shouldUseHTTP(content string) bool {
switch p.sendViaHTTP {
case "always":
return p.httpURL != ""
case "never":
return false
default: // "auto" or unset
return p.httpURL != "" && len(content) > defaultHTTPFallbackThreshold
}
}

// SendImage sends an image to the conversation.
// Implements core.ImageSender.
func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error {
Expand Down
Loading
Loading