Skip to content
Merged
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
85 changes: 67 additions & 18 deletions plans/2026-09-05-dm-address-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@

各 channel 的对应关系如下,均为调研结论:

- Telegram:按对方是不是 bot 分流。`sendMessage` 的 `chat_id` 文档写的是「target **bot**, supergroup or
channel 的 @username」,普通用户不在其列,因此**只有 bot 才用 `@username`,人一律用数字 id**;
私聊的 chat_id 与对方的用户 id 相同。bot 没有 username 时退回数字 id,不构造飞书会拒绝的地址。
Bot API 10.0(2026-05-08)起,两个机器人在双方于 BotFather 打开开关后可以按 `@username` 互发私聊;
该开关无法探测(`getMe` 的 `User` 没有对应字段),因此这条是乐观的,成立与否由发送结果决定。
为承载 username,`TelegramThreadIdentity.chat_id` 由 `int` 放宽为 `int | str`:数字仍按原样解析,
历史身份串的 uuid5 因此不变,已有会话不会被重新编号
- Telegram:取 `SenderIdentity.id`,即对方的用户 id,私聊的 chat_id 与之相同,人与 bot 同样对待。
`@username` 只用于**打开一个尚不存在的私聊**:Bot API 10.0(2026-05-08)的 changelog 写的是
「Added the ability to send messages to other bots via username if both bots enabled bot-to-bot
communication」,文档中没有一处说 bot 可以用数字 id 作为尚未建立的私聊目标;而私聊一旦存在,数字
chat_id 即可送达(本节点向 `telegram:<bot>:7181589532:0` 发出的四条出站消息均为 `sent`)。该开关无法
探测(`getMe` 的 `User` 没有对应字段),因此首次发送是乐观的,成立与否由发送结果决定。
`TelegramThreadIdentity.chat_id` 保持 `int | str`:历史身份串仍按原样解析,其 uuid5 不变
- 飞书:取 `SenderIdentity.id`,即 `open_id`,而 `open_id` 是 `im/v1/messages` 的 `receive_id_type` 合法取值,
可直接作为 `receive_id`。`im.message.receive_v1` 的 `event_sender` 结构为
`sender_id{union_id, user_id, open_id}` 加 `sender_type`(取值 `user` 或 `bot`),两种发送者共用同一结构,
Expand All @@ -49,11 +49,10 @@
1. 按现有逻辑解析 `dm:@name`,命中即结束。
2. 未命中且目标形如 `dm:@name` 时,在当前 actor 可达范围内按 handle 查历史入站发送者,
取其 `sender_id` 与所属 channel。
3. 把该身份交给 channel 换取私聊的 provider 地址。
4. 换到地址则建立 `channel_sessions` 与 `threads` 两行并重新解析;换不到则抛
`InboxTargetResolutionError`,`bcc` 调用方看到的仍是「找不到」。

发送失败不回滚已建立的映射。地址换不出来时没有别的补救手段,保留映射与删除映射对调用方没有区别。
3. 把该身份交给 channel 换取私聊的 provider 地址;换不到则抛 `InboxTargetResolutionError`,
`bcc` 调用方看到的仍是「找不到」。
4. 该地址对应的会话可能已经在库里、只是这个 token 找不到它;按 `(channel, provider_thread_id)`
查到就按它继续,查不到才按「落库在投递之后」一节打开新的一段对话。

`dm:@name` 中的 `name` 就是 agent 在消息头 `@` 位置上看见的那一段。该位置由
`resources/command/sender.tpl` 渲染:有 handle 时是 handle,没有时是 `sender_id`;显示名只出现在括号里,
Expand All @@ -80,6 +79,41 @@
看到并且可以照抄的那一段(`resources/command/sender.tpl` 在没有 handle 时渲染 `@<sender_id>`),
因此按 `messages.sender_id` 匹配这一级足以覆盖飞书,不需要为飞书补 handle。

## 落库在投递之后

出站消息在拿到 provider 的答复之后才落库,一次写入即终态:投递之前不存在 `pending` 行。代价是进程在
发出与写入之间挂掉会留下「对方收到了、本地没有」的窗口;换来的是本地不会记下一件没有发生的事,也不必
把先落库的猜测再改正。

落库的条件只有一条,对所有会话一视同仁:**这条消息有一部分到了对方那里**——终态是
`sent`/`queued`/`partial`,或者回执里带回了 provider 给的消息 id 或会话 id。没到就只记审计后返回:
消息历史是这段对话本身,一条没送出去的东西不属于它;失败的经过在审计里查得到。

由此一条命令只尝试一次:库里有它的出站消息,就说明它已经到过对方那里,不再重发;没到过的可以重试,
那正是重试的意义。在途的 command_id 记在内存里,写库后移除。

这条顺序对「打开一个尚不存在的私聊」尤其要紧。这样的会话没有 provider 身份可写——`@username` 是投递
地址,不是身份;把它写进会话行会让「发出去的」和「回来的」落在两行上,同一段对话被劈成两半。因此:

- `dm:@name` 解析不到且该发送者说过话时,取 channel 给出的 `DmAddress`(含 `provider_thread_id` 与
`delivery_handle`),**先把消息发出去**。
- `sendMessage` 成功时返回发出的那条 Message,其中必然带 `chat`,因此回执附带 provider 自己认定的会话
id,与本次用什么形式寻址无关。`ChannelDeliveryReceipt` 与 `OutboundDeliveryResult` 携带
`provider_thread_id`,由 channel 从回执中取出并按自己的身份串格式拼出。
- 投递之后按该 id 建立 `channel_sessions` 与 `threads`,再把这条出站消息连同终态一并写入。回执没有给出
id 时退回 `DmAddress` 给的那个。
- **一部分都没到就什么都不写**:一段没被打开的对话不是对话,写下来只会让 `dm:@name` 解析到一个发不出去
的会话,之后每次重试都走普通路径、不带打开它所需的名字。
- 找到的会话若没有 thread,说明上一次只写了一半,按尚未打开处理。
- 这条路不经过草稿与新鲜度校验:一段尚不存在的对话没有未读可言。

会话行的 `created_at_ms` 不参与身份比较:同一段对话可能在投递在途时已被另一条路打开,此时该以先写下的
那一刻为准,而不是把两次的时刻之差当成身份变化。

会话行上不保存任何投递用的名字,也就没有「用完要清」这回事。`delivery_handle` 只存在于 `DmAddress`
与 `ChannelSendRequest`,随这一次发送流转。telegram 出站在有该值时用 `@handle` 作为 `chat_id`,否则用
身份中的数字 id;打字状态的路由仍按数字身份注册。

## Tasks

### Task 1:按 `@` 位置的取值查历史发送者
Expand All @@ -98,22 +132,37 @@
两个 id 由各 channel 用**自己的身份串**做 uuid5 得出,core 无法从 `provider_thread_id` 推导。若由 core
自行编号,等对方之后真的发消息进来,channel 会算出另一组 id,同一个人会出现两条会话。

`sender_kind` 供 Telegram 区分 bot 与人,其余 channel 忽略。该参数不进入 agent 的视野:agent 始终只写
`dm:@name`,人与 bot 的差别只存在于内部解析。
`sender_kind` 用于排除来路不明的发送者:以频道或匿名管理员身份发到群里的消息带的是该群的 id 而没有
`from`,按它建立的私聊会把内容发回原群。它同时决定 `delivery_handle`——需不需要一个名字才能打开这段
对话、什么样的名字算数,只有 channel 知道。该参数不进入 agent 的视野:agent 始终只写 `dm:@name`。

### Task 3:未命中时建立映射

在 `command.py` 的 `send` 解析点接入 Task 1 与 Task 2,按设计一节的流程建立 `channel_sessions` 与
`threads` 两行后重新解析。新建的 dm 行写入 `target_handle` 与 `target_handle_key`,使后续解析直接命中
第一步
在 `command.py` 的 `send` 解析点接入 Task 1 与 Task 2,按设计一节的流程发送并在其后建立
`channel_sessions` 与 `threads` 两行。新建的 dm 行写入 `target_handle` 与 `target_handle_key`,使后续
解析直接命中第一步

只接 `send`,不接 `unfollow`:为一个尚不存在的会话建立映射只为了取消关注没有意义。

`materialize_outbound_if_fresh` 原本要求目标会话至少有一条入站消息,否则报「target is not replyable」。
冷启动私聊的目标会话必然零入站,与该校验直接冲突。该校验没有任何测试覆盖,也不来自任何既定要求,
因此删除;配套的 `ErrorKind.TARGET_NOT_REPLYABLE` 随之成为死代码,一并删除。

### Task 4:端到端测试
### Task 4:迁移既有的按 handle 命名的会话

按 handle 命名的 dm 行的正身可从入站消息推出:入站同时带着对方说话时用的 handle 与其 `sender_id`,
取该 handle 下 seq 最大的一条,与解析时的取法一致。据此:

- 对方已经有按其 id 命名的会话时,把按 handle 命名的那行的 messages、thread、reminder 与 cursor
并入该会话后删除该行。消息上的 `provider_thread_id` 不改写——它记录的是该条消息当时如何被寻址;
`target` 改写为留存会话的 `canonical_target`。
- 对方还没有这样的会话时,就地把该行改名为按 id 命名。

cursor 合并取「两边未读中最小的 seq 减一」:`delivered_through_seq` 比较的是全局 seq,取两者较大值会把
另一侧尚未读到的消息判为已送达。配对只在同一 channel 内进行,provider 身份的定义是
`(agent_id, channel, provider_thread_id)`。

### Task 5:端到端测试

用 TestChannel 注入一条群消息,再以该发送者的 handle 执行 `bcc message send --target dm:@name`,
验证映射被建立且消息送达。TestChannel 需要具备解析能力以覆盖成功路径,并能返回 `None` 以覆盖「找不到」路径。
11 changes: 7 additions & 4 deletions src/bazaar_compute_node/contrib/sqlite/codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,13 @@ def validate_outbound_message_input(message: object) -> None:


def validate_outbound_insert(message: Message[OutboundAttachment]) -> None:
if message.delivery_state is not OutboundDeliveryState.PENDING:
raise ValueError("a new outbound message must start in pending state")
if any(
"""Check a message written once the provider has already answered.

An outbound is recorded after its attempt, so it arrives carrying whatever
became of it; only a message still waiting may claim to know nothing.
"""

if message.delivery_state is OutboundDeliveryState.PENDING and any(
value is not None
for value in (
message.provider_message_id,
Expand Down Expand Up @@ -442,7 +446,6 @@ def validate_channel_session_update(
existing.channel != incoming.channel
or existing.provider_thread_id != incoming.provider_thread_id
or existing.target_kind is not incoming.target_kind
or existing.created_at_ms != incoming.created_at_ms
):
raise ValueError("channel session identity cannot change")
_validate_updated_at(existing.updated_at_ms, incoming.updated_at_ms)
Expand Down
2 changes: 2 additions & 0 deletions src/bazaar_compute_node/contrib/sqlite/migrations/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from .v24_persist_sender_display_name import SENDER_DISPLAY_NAME_MIGRATION
from .v25_rename_sessions_to_threads import THREAD_RENAME_MIGRATION
from .v26_remove_handoff_messages import HANDOFF_MESSAGE_REMOVAL_MIGRATION
from .v27_name_dms_by_the_peer_id import NAME_DM_BY_PEER_ID_MIGRATION

if TYPE_CHECKING:
from ..executor import SqliteSession
Expand Down Expand Up @@ -85,6 +86,7 @@ def _migration_ledger(*migrations: Migration) -> tuple[Migration, ...]:
SENDER_DISPLAY_NAME_MIGRATION,
THREAD_RENAME_MIGRATION,
HANDOFF_MESSAGE_REMOVAL_MIGRATION,
NAME_DM_BY_PEER_ID_MIGRATION,
)


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
from __future__ import annotations

from .model import Migration

# A DM opened by handle used to keep that handle as its name, while the peer
# speaks under its own id, so one conversation could end up stored as two. The
# peer's id is recoverable from what it said: an inbound message carries both
# the handle it spoke under and the id it spoke from. A handle can have been
# worn by more than one peer, and the one that opened this conversation is the
# one that spoke last under it.
_RENAMED = """
SELECT
opened.agent_id AS agent_id,
opened.channel AS channel,
opened.id AS opened_id,
REPLACE(
opened.provider_thread_id,
':@' || opened.target_handle || ':',
':' || spoken.sender_id || ':'
) AS peer_thread_id
FROM channel_sessions AS opened
JOIN messages AS spoken
ON spoken.agent_id = opened.agent_id
AND spoken.channel = opened.channel
AND spoken.direction = 'inbound'
AND spoken.sender_id IS NOT NULL
AND LOWER(spoken.sender) = opened.target_handle_key
AND spoken.seq = (
SELECT MAX(latest.seq) FROM messages AS latest
WHERE latest.agent_id = opened.agent_id
AND latest.channel = opened.channel
AND latest.direction = 'inbound'
AND latest.sender_id IS NOT NULL
AND LOWER(latest.sender) = opened.target_handle_key
)
Comment on lines +28 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid assigning old DMs to the latest owner of a reused handle

When a handle has been worn by multiple peers, choosing the globally latest inbound speaker does not identify who owned the handle when the historical DM was opened. For example, if peer A received messages through @kana and peer B later acquires that handle and speaks in a group, this migration attributes A's stored private outbound history to B and may merge it into B's numeric conversation. Do not migrate ambiguous reused-handle histories without evidence tying the opened session to the selected sender ID.

Useful? React with 👍 / 👎.

WHERE opened.target_kind = 'dm'
AND opened.target_handle IS NOT NULL
AND INSTR(opened.provider_thread_id, ':@' || opened.target_handle || ':') > 0
"""

# Where the peer already has a conversation under its own id, that one is the
# conversation, and what was written under the handle joins it.
_MERGED = f"""
SELECT
renamed.agent_id AS agent_id,
renamed.opened_id AS opened_id,
opened_thread.id AS opened_thread_id,
peer.id AS peer_id,
peer_thread.id AS peer_thread_id,
'dm:' || peer.id AS peer_target
FROM ({_RENAMED}) AS renamed
JOIN channel_sessions AS peer
ON peer.agent_id = renamed.agent_id
AND peer.channel = renamed.channel
AND peer.provider_thread_id = renamed.peer_thread_id
JOIN threads AS opened_thread
ON opened_thread.agent_id = renamed.agent_id
AND opened_thread.channel_session_id = renamed.opened_id
JOIN threads AS peer_thread
ON peer_thread.agent_id = renamed.agent_id
AND peer_thread.channel_session_id = peer.id
"""

# A cursor says what has been delivered, so the merged one may not pass a
# message that either side still had waiting.
_UNREAD_FLOOR = """
SELECT MIN(waiting.seq) FROM messages AS waiting
JOIN consumer_cursors AS reading
ON reading.thread_id = waiting.thread_id
WHERE waiting.thread_id IN (merged.opened_thread_id, merged.peer_thread_id)
AND waiting.direction = 'inbound'
AND waiting.seq > reading.delivered_through_seq
"""

NAME_DM_BY_PEER_ID_MIGRATION = Migration(
version=27,
name="name_dms_by_the_peer_id",
statements=(
# the rows that identify this work are the rows it rewrites, so settle
# the set before touching any of them
f"""
CREATE TEMPORARY TABLE bcn_merged_dms AS {_MERGED}
""",
f"""
CREATE TEMPORARY TABLE bcn_renamed_dms AS
SELECT * FROM ({_RENAMED}) AS renamed
WHERE renamed.opened_id NOT IN (SELECT opened_id FROM bcn_merged_dms)
""",
f"""
UPDATE consumer_cursors SET delivered_through_seq = COALESCE(
(
SELECT ({_UNREAD_FLOOR}) - 1 FROM bcn_merged_dms AS merged
WHERE merged.peer_thread_id = consumer_cursors.thread_id
),
MAX(
delivered_through_seq,
COALESCE((
SELECT waiting.delivered_through_seq
FROM consumer_cursors AS waiting
JOIN bcn_merged_dms AS merged
ON merged.opened_thread_id = waiting.thread_id
WHERE merged.peer_thread_id = consumer_cursors.thread_id
), 0)
)
)
WHERE thread_id IN (SELECT peer_thread_id FROM bcn_merged_dms)
""",
"""
DELETE FROM consumer_cursors
WHERE thread_id IN (SELECT opened_thread_id FROM bcn_merged_dms)
""",
"""
UPDATE reminders SET owner_thread_id = (
SELECT peer_thread_id FROM bcn_merged_dms
WHERE bcn_merged_dms.opened_thread_id = reminders.owner_thread_id
)
WHERE owner_thread_id IN (SELECT opened_thread_id FROM bcn_merged_dms)
""",
# provider_thread_id stays as it was on a message: it records how that
# message was addressed, which no later merge changes
"""
UPDATE messages SET
thread_id = (
SELECT peer_thread_id FROM bcn_merged_dms
WHERE bcn_merged_dms.opened_id = messages.channel_session_id
),
target = (
SELECT peer_target FROM bcn_merged_dms
WHERE bcn_merged_dms.opened_id = messages.channel_session_id
),
channel_session_id = (
SELECT peer_id FROM bcn_merged_dms
WHERE bcn_merged_dms.opened_id = messages.channel_session_id
)
WHERE channel_session_id IN (SELECT opened_id FROM bcn_merged_dms)
""",
"""
DELETE FROM threads WHERE id IN (SELECT opened_thread_id FROM bcn_merged_dms)
""",
"""
DELETE FROM channel_sessions WHERE id IN (SELECT opened_id FROM bcn_merged_dms)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Carry the readable handle into the retained DM

When the numeric DM predates migration v22, its target_handle is null; if the later handle-addressed implementation created a second row for that peer, this merge deletes the only row carrying the handle without copying it to the retained numeric session. After migration, direct operations such as message read --target dm:@name and thread unfollow --target dm:@name no longer resolve until another inbound message refreshes the numeric session. Copy the opened row's handle fields when the retained row has none before deleting it.

Useful? React with 👍 / 👎.

""",
# the rest have no second half yet, so they only need their own name
"""
UPDATE channel_sessions SET provider_thread_id = (
SELECT peer_thread_id FROM bcn_renamed_dms
WHERE bcn_renamed_dms.opened_id = channel_sessions.id
)
WHERE id IN (SELECT opened_id FROM bcn_renamed_dms)
Comment on lines +145 to +149

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve channel routing aliases for renamed DM threads

For a handle-based session with no numeric counterpart, this updates only provider_thread_id while retaining channel-session and thread IDs derived from the old @handle identity. After restart, a numeric inbound causes Channel.receive to register its provider-session mapping under the numeric-derived local thread ID, but storage resolves the message to the retained handle-derived thread ID; subsequent accept_turn_event calls cannot translate that ID and Telegram's _stream_routes lookup misses, so typing actions disappear for turns triggered by these migrated DMs. Preserve an alias for the retained ID or migrate the local identity consistently.

Useful? React with 👍 / 👎.

Comment on lines +162 to +166

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Merge same-peer handle sessions before renaming

When one peer was contacted under two successive handles and never created a numeric session, both handle-backed rows remain in bcn_renamed_dms because _MERGED only detects an already-existing numeric row. This update then assigns both rows the same numeric provider_thread_id; subsequent find_channel_session calls see multiple matches and raise multiple rows violate channel provider identity, breaking inbound processing and further sends for that DM. Coalesce rows that resolve to the same peer identity before applying the rename.

Useful? React with 👍 / 👎.

""",
"""
DROP TABLE bcn_merged_dms
""",
"""
DROP TABLE bcn_renamed_dms
""",
),
)

__all__ = ["NAME_DM_BY_PEER_ID_MIGRATION"]
12 changes: 11 additions & 1 deletion src/bazaar_compute_node/contrib/sqlite/repository/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,16 @@ async def _available_message_id(self, message_id: str) -> str:
raise ValueError("Agent-scoped message id is already in use")
return message_id

async def has_outbound_for_command(self, command_id: str) -> bool:
return (
await self.fetchone(
"SELECT 1 FROM messages WHERE agent_id = /*agent_id*/? "
"AND command_id = ? AND direction = 'outbound'",
(command_id,),
Comment thread
yuchanns marked this conversation as resolved.
)
is not None
)

async def _resolve_reply(
self, canonical: Message[InboundAttachment]
) -> Message[InboundAttachment]:
Expand Down Expand Up @@ -949,7 +959,7 @@ async def _insert_outbound(

canonical = replace(
message,
message_id=str(uuid7()),
message_id=message.message_id or str(uuid7()),
seq=await self._next_message_seq(),
channel=channel_session.channel,
provider_thread_id=channel_session.provider_thread_id,
Expand Down
1 change: 1 addition & 0 deletions src/bazaar_compute_node/contrib/sqlite/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"find_thread",
"find_channel_session",
"find_known_sender",
"has_outbound_for_command",
"find_message",
"get_thread",
"get_channel_session",
Expand Down
Loading