Skip to content

fix(qq): 主动私聊被当成群聊发送(v4) - #443

Open
gyf9835 wants to merge 1 commit into
satorijs:v4from
gyf9835:fix/qq-send-private-message-v4
Open

gyf9835 wants to merge 1 commit into
satorijs:v4from
gyf9835:fix/qq-send-private-message-v4

Conversation

@gyf9835

@gyf9835 gyf9835 commented Nov 1, 2026

Copy link
Copy Markdown

目标分支:v4(上一版误开了 main 上的 PR #442,已关闭)

问题

使用 adapter-qq 主动发送单聊消息(如 bot.sendPrivateMessage(openid, content)、echo -u @某人)时,用户 openid 被当成群 openid,实际请求了群聊接口:

POST /v2/groups/{user_openid}/messages
→ { message: 'invalid request', code: 11255 }

在 Koishi 里的表现是一条无信息的 Error(QQMessageEncoder.send → Proxy.sendMessage),只提示「发生未知错误」。不指定用户时为群聊则正常,指定用户就报错。

根因

@satorijs/core 的 Bot.sendPrivateMessage() 只使用了 createDirectChannel() 返回的 id,丢弃了 type: DIRECT:

const { id } = await this.createDirectChannel(userId, guildId ?? options?.session?.guildId)
return this.sendMessage(id, content, null, options)   // type 丢失

而 MessageEncoder.send() 在没有 options.session 时构造的伪 session channel.type 不是 DIRECT,于是 flush() 中的判断恒为假:

const resp = this.session.isDirect
  ? await this.bot.internal.sendPrivateMessage(...)   // /v2/users/{openid}/messages
  : await this.bot.internal.sendMessage(...)          // /v2/groups/{openid}/messages ← 实际走到这里

QQ 群 openid 与用户 openid 格式完全相同,无法从 id 区分,因此只能由调用侧显式传递意图。

修改

  • adapters/qq/src/types.ts:通过 declare module '@satorijs/protocol' 为 SendOptions 增加内部使用的 direct?: boolean 字段。
  • adapters/qq/src/bot/index.ts:覆写 sendPrivateMessage(),打上 direct: true 后再走 sendMessage()。
  • adapters/qq/src/message.ts:
    • QQMessageEncoder 通过 isDirectSend getter 读取该标记;
    • flush() 用 isDirectSend || session.isDirect 选择单聊/群聊接口,并修正主动发送时使用的 channelId(原逻辑依赖 this.session.channelId,主动发送时是伪 session);
    • sendFile() 同样修正私聊文件上传的目标 id(原逻辑取 options.session.userId,主动发送时不存在)。
  • adapters/qq/package.json:补充 @satorijs/protocol 的 peer/dev 依赖(SendOptions 来源)。

被动回复(session.send()、ctx.broadcast())路径行为不变。

验证

用 @satorijs/core@4.6.1、@satorijs/protocol@1.7.0 的类型定义对改动的三个文件做了 tsc --noEmit,无新增类型错误(仓库全量依赖因 @cordisjs/url-is-local 等包在 npm 上缺失而无法 yarn install,见 #432)。

关联

Closes #304

另见 koishijs/koishi#1456、koishijs/koishi#1485、#382(同一根因的不同入口)。


署名

本 PR 的改动由 AI 编码助手 Maki(deepseek/deepseek-v4-flash)在用户 gyf9835 的指导下定位与实现;分析与提交内容由 Maki 生成,请审阅后再合并。

主动私聊被错误地当成群聊发送:core 的 sendPrivateMessage 只使用
createDirectChannel 返回的 id,channel type 在 MessageEncoder 中丢失,
导致 flush() 里 session.isDirect 恒为 false,最终请求
POST /v2/groups/{user_openid}/messages 而报 invalid request (11255)。

由于群 openid 与用户 openid 格式相同,无法从 id 区分,因此:
- QQBot.sendPrivateMessage 通过 options.direct 显式标记主动私聊
  (在 types.ts 中扩展 SendOptions);
- QQMessageEncoder 记录该标记,flush() / sendFile() 据此选择单聊接口,
  并修正主动发送时使用的 channelId(原逻辑依赖 this.session.channelId)。

Refs satorijs#304
@gyf9835

gyf9835 commented Nov 1, 2026

Copy link
Copy Markdown
Author

说明:本 PR 仅修复 -u(主动私聊),-c / -g(显式指定频道/群组)无法在本层修复

背景

@satorijs/adapter-qq 的群消息编码器在判断「发到用户」还是「发到群」时,只依据会话的 isDirect:

// adapters/qq/src/message.ts
const resp = this.session.isDirect
? await this.bot.internal.sendPrivateMessage(this.session.channelId, data)
: await this.bot.internal.sendMessage(this.session.channelId, data)

而 bot.sendPrivateMessage() 经由 createDirectChannel() 拿到目标 id 后,createDirectChannel() 返回的 type: DIRECT 被 Bot.sendMessage() 丢弃,sendPrivateMessage 本身又是主动发送、不带 session,因此 isDirect 恒为 false,最终错误地调用了群接口,把用户 openid 当成群 openid。

本 PR 通过在 encoder 上引入 directTarget 标志并让 QQBot.sendPrivateMessage() 构造 encoder 时显式置位,使 -u 场景正确路由到 /v2/users/{openid}/messages,同时把寻址统一收敛到构造时传入的 channelId(避免 options.session 浅合并污染目标 id)。

该修复对应 #304。

为什么 -c / -g 无法在 adapter 层修复

Koishi 的 echo 插件对三种参数的实际调用是:

// koishijs/plugin-echo
if (options.user) {
await bot.sendPrivateMessage(id, content, session.guildId) // -u
} else {
await bot.sendMessage(id, content, options.guild) // -c / -g
}

bot.sendMessage(channelId, content, referrer?, options?) 的第三个参数是 Referrer,不是 guildId。也就是说:

  1. 目标 id 确实传进来了,但「这是一条发往群 / 发往频道」的意图没有被传递。
  2. createMessage() 构造 encoder 时,SendOptions.session 为空;encoder 只能去看 this.session.isDirect。
  3. 该 session 由 MessageEncoder.send() 从 options.session?.event.channel 浅合并而来 —— 当 -g 在私聊中执行时,它就是那条私聊 session,isDirect === true。
  4. 结果:-g <群号> 实际请求了 /v2/users/<群号>/messages,消息发回给了操作者自己。

QQ 的群 openid 与用户 openid 格式完全相同,adapter 无法从 id 字符串反推出目标类型,因此这不是一个“补全判断逻辑”就能解决的问题:信息在 echo → Bot.sendMessage 这一跳就已经丢失,adapter 层没有可依赖的输入。

同理,任何「在 encoder 里加一个 guildTarget 标志」的做法都是无效设计 —— 没有任何调用方会去设置它。

修复 core 需要的改动(本 PR 不含,暂缓)

要正确支持 -c / -g,需要在 @satorijs/core 把「目标会话类型」透传到 encoder。核心是让 Bot.sendMessage 能够接收并传递一个显式的目标类型,而不是依赖 options.session 的副作用。

  1. packages/protocol/src/message.ts — 扩展 SendOptions:

export interface SendOptions {
session?: Session
/** 显式指定目标频道类型,优先于 session 推断 /
channelType?: Channel.Type
/
* 显式指定目标所属群组 id */
guildId?: string
}

  1. packages/core/src/bot.ts — 让 sendMessage 接收并透传:

async createMessage(channelId: string, content: h.Fragment, referrer?: any, options?: SendOptions) {
const { MessageEncoder } = this.constructor as typeof Bot
return new MessageEncoder!(this, channelId, referrer, options).send(content)
}

async sendMessage(
channelId: string,
content: h.Fragment,
referrer?: any,
options?: SendOptions,
) {
const messages = await this.createMessage(channelId, content, referrer, options)
return messages.map((message) => message.id).filter(isNonNullable)
}

即保持签名不变,由调用方通过 options.channelType / options.guildId 表达意图。

  1. packages/core/src/message.ts — MessageEncoder 在构造 session 时优先采用显式类型:

async send(content: h.Fragment) {
this.session = this.bot.session({
type: 'send',
channel: {
id: this.channelId,
type: this.options.channelType,
...this.options.session?.event.channel,
} as Channel,
guild: this.options.session?.event.guild,
})
// ...
}

注意这里仍需保证 id 不被 options.session.event.channel.id 覆盖,建议改为:

channel: {
...this.options.session?.event.channel,
id: this.channelId,
...(this.options.channelType != null ? { type: this.options.channelType } : {}),
} as Channel,

  1. packages/core/src/session.ts — isDirect 的判定保持基于 event.channel.type 即可,无需改动;上述改动后它会自动正确。

  2. adapters/qq/src/bot/index.ts — encoder 侧改为读取显式类型:

private get targetIsDirect() {
const type = this.options?.channelType ?? this.options?.session?.event.channel?.type
if (type != null) return type === Universal.Channel.Type.DIRECT
return this.session.isDirect
}

  1. koishijs/plugin-echo(不在 satori 仓库)— 调用方需要显式传类型:

if (options.user) {
await bot.sendPrivateMessage(id, content, session.guildId)
} else {
await bot.sendMessage(id, content, null, {
session,
channelType: Universal.Channel.Type.TEXT,
guildId: options.guild,
})
}

结论

╭───────────────┬────────────┬───────────────────────────────────────────────────────────────────────╮
│ 场景 │ 状态 │ 原因 │
├───────────────┼────────────┼───────────────────────────────────────────────────────────────────────┤
│ echo -u @用户 │ 本 PR 修复 │ 意图明确(sendPrivateMessage),只需在 adapter 内补 directTarget 标志 │
├───────────────┼────────────┼───────────────────────────────────────────────────────────────────────┤
│ echo -c @频道 │ 不修复 │ 目标类型未从上传递,adapter 无从判断 │
├───────────────┼────────────┼───────────────────────────────────────────────────────────────────────┤
│ echo -g @群组 │ 不修复 │ 同上;且在私聊中执行时会被误判为私聊 │
╰───────────────┴────────────┴───────────────────────────────────────────────────────────────────────╯

-c / -g 的修复依赖 @satorijs/core 增加 SendOptions.channelType / guildId 透传,并同步修改 koishijs/plugin-echo 的调用方式。该改动涉及跨仓库接口约定,本 PR 暂不包含,待接口方案确定后单独提交。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant