feat: 支持 OneTalk 文本引用回复协议 (#66)

* feat: add OneTalk quote reply support

* chore(task): archive 09-17-onetalk-quote-reply-protocol

* chore: record journal

* docs: format OneTalk DOM card collection plan

* chore: release 0.8.29

* chore: release 0.8.30
This commit is contained in:
YBF
2026-09-17 11:56:35 +08:00
committed by GitHub
parent 826980e97f
commit 784bbec2a0
45 changed files with 1403 additions and 44 deletions
@@ -0,0 +1,136 @@
# OneTalk 引用回复:Mind 对接说明
> 适用范围:Mind 已接入 Bright OneTalk WebSocket 的文本发送链路。本说明只增加引用目标字段,不改变连接、授权、普通发送或发送结果协议。
## 1. 能力与边界
Mind 在 OneTalk 时间线中选择一条**文本消息**后,可以发送一条引用该消息的新文本。Mind 只向 Bright 提交被引用消息的 OneTalk `messageId`;Bright 和插件负责在同一账号、同一会话内解析 OneTalk SDK 所需的原始引用对象。
Mind 不得发送、保存或展示下列数据:
- OneTalk SDK `referMessage` / `originalData`
- `.PNM` 后缀的 SDK 传输别名;
- 根据正文、时间、发送人或 UI 序号推导出的 ID;
- Mind 数据库 UUID(它不是 OneTalk `messageId`)。
第一版只支持“引用文本后发送文本”。图片和文件仍走既有普通发送,不能附带引用字段。
## 2. 引用目标的来源
从 Bright 返回的 OneTalk 时间线消息中读取该条消息的 `id` / `messageId`,将其原样作为 `replyToMessageId`
```ts
type ReplyTarget = {
messageId: string; // Bright OneTalk messageId,不是 Mind UUID
from: string;
text: string;
};
```
仅当时间线行满足以下条件时显示“回复”入口:
- 消息正文是非空文本;
- `messageId` 为非空字符串;
- 消息属于当前 OneTalk 会话,且不是 system、optimistic 或本地临时消息。
`messageId` 不做 trim、拼接或格式转换;例如 `123456.PNM` 不是合法的 `replyToMessageId`
## 3. 发送帧增量
沿用既有 `send.request` WebSocket 帧。在当前共享协议版本 **9** 中,引用发送的 payload 必须恰好为以下形状:
```json
{
"conversationId": "c123",
"content": {
"kind": "text",
"text": "这条消息是在回复前文"
},
"replyToMessageId": "123456"
}
```
完整帧示例(`scope``requestId``sendRequestId` 继续使用当前 Mind 的既有生成与关联逻辑):
```json
{
"protocolVersion": 9,
"connectionType": "mind_page",
"type": "send.request",
"requestId": "mind-frame-001",
"sendRequestId": "mind-send-001",
"scope": {
"workspaceId": "workspace-1",
"mindUserId": "mind-user-1",
"channelAccountId": "286995452"
},
"payload": {
"conversationId": "c123",
"content": {
"kind": "text",
"text": "这条消息是在回复前文"
},
"replyToMessageId": "123456"
}
}
```
普通发送保持原样,**省略** `replyToMessageId`
```json
{
"conversationId": "c123",
"content": { "kind": "text", "text": "普通文本" }
}
```
不要把 `replyToMessageId` 放到 `content``ext` 或其他扩展对象中;协议采用严格字段校验,未知字段或错误层级会被拒绝。
## 4. Mind 侧校验
发起引用发送前,Mind 必须确认:
- `conversationId` 是当前会话的 OneTalk `conversationId`
- `replyToMessageId` 非空、没有首尾空白,且不是纯数字加 `.PNM`
- `content.kind === "text"``content.text` 非空;
- 当前 WebSocket scope 中的 `channelAccountId` 与时间线消息所属账号一致。
不要在客户端把无效引用降级为普通文本发送。若本地无法确定目标 ID,应禁用发送并提示用户重新选择消息。
## 5. Composer 交互
点击“回复”后,Mind 只保留临时 UI 状态 `{ messageId, from, text }`:composer 中显示发送者和截断文本摘要,并提供“取消引用”。
以下场景必须清除该临时状态:
- 切换 OneTalk 会话;
- 收到 `confirmed_sent`
- 收到 `rejected_before_send`
收到 `delivery_unknown` 时,不能自动重试或伪造发送成功;应保留既有“结果未知”的可见状态,由用户决定后续操作。
第一版不要求 Mind 解析或重新绘制远端消息里的引用卡片。引用内容由 OneTalk 页面渲染,Mind 时间线仍按 Bright 已返回的规范化消息展示。
## 6. 结果处理
`send.result` 与普通发送使用同一关联键 `sendRequestId`,结果语义不变:
| status | Mind 行为 |
| ---------------------- | --------------------------------------------------- |
| `confirmed_sent` | 将返回的已发送消息并入时间线,清除引用临时状态。 |
| `rejected_before_send` | 显示 `reason`,清除引用临时状态;不得改发普通文本。 |
| `delivery_unknown` | 显示结果未知;不得自动重试、不得假定消息已发送。 |
常见拒绝原因包括 `invalid_request`(引用 ID 不合法、引用目标不在当前会话或不支持的内容类型)、`waiting_for_page``page_not_found``page_identity_mismatch``send_not_supported`。Mind 应直接展示已有的发送错误状态,不自行转换错误码。
## 7. 联调验收
1. 加载 OneTalk 会话与文本消息,确认时间线行使用 Bright 返回的真实 `messageId`
2. 选择一条文本消息,发送引用文本,检查发送帧只有顶层 `payload.replyToMessageId`
3. 确认 `send.result` 与同一个 `sendRequestId` 关联,并仅在 `confirmed_sent` 时显示发送成功。
4. 验证切换会话、取消引用与 `rejected_before_send` 都会清除 composer 的引用状态。
5. 验证普通文本、图片和文件发送均不携带 `replyToMessageId`,原有行为不变。
## 8. 兼容性
Mind、Bright 与 Chrome 插件必须使用兼容的 OneTalk 协议版本部署。当前源码的协议版本为 9;上线前以实际运行中的共享 contract 为准,不要让旧客户端向新协议帧附加未知字段。
@@ -23,21 +23,32 @@
### Task 1: DOM reader and legacy reader removal
**Files:**
- Create: `apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader.ts`
- Delete: `apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts`
- Modify: `apps/chrome-extension/src/onetalk/main-page/card-observer/entry.ts`
- Test: `apps/chrome-extension/test/onetalk-rendered-card-reader.test.js`
**Interfaces:**
- Produces: `readOneTalkRenderedCardFromDom(pageWindow, card): OneTalkRenderedCardObservation | null`.
- Consumes: `readConversationSelection`, `normalizeOneTalkProductUrl`, rendered-card validators and fingerprint creator.
- [ ] **Step 1: Replace Fiber fixtures with DOM fixtures and red tests.**
```js
assert.equal(readOneTalkRenderedCardFromDom(page, cardWith({ cardMessageId: "m-1", wrapperMessageId: "m-2" })), null);
assert.equal(
readOneTalkRenderedCardFromDom(
page,
cardWith({ cardMessageId: "m-1", wrapperMessageId: "m-2" }),
),
null,
);
assert.equal(readOneTalkRenderedCardFromDom(page, quoteCard({ cardType: 8 })), null);
assert.equal(readOneTalkRenderedCardFromDom(page, productCard({ rawUrl: "https://bad.example" })), null);
assert.equal(
readOneTalkRenderedCardFromDom(page, productCard({ rawUrl: "https://bad.example" })),
null,
);
```
- [ ] **Step 2: Run the focused reader test and confirm the missing DOM reader fails.**
@@ -50,7 +61,8 @@ node --experimental-strip-types --test apps/chrome-extension/test/onetalk-render
```ts
const messageId = cardMessageId ?? wrapperMessageId;
if (!messageId || (cardMessageId && wrapperMessageId && cardMessageId !== wrapperMessageId)) return null;
if (!messageId || (cardMessageId && wrapperMessageId && cardMessageId !== wrapperMessageId))
return null;
if (!sameOptionalConversation(cardInfo, wrapperInfo)) return null;
```
@@ -59,8 +71,14 @@ Construct only exact `rendered_inquiry`, `rendered_product`, or `rendered_order`
- [ ] **Step 4: Rewrite entry to scan cards and react only to DOM mutations.**
```ts
for (const card of cardsForMutation(record)) publish(readOneTalkRenderedCardFromDom(pageWindow, card));
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-expinfo", "src", "style"] });
for (const card of cardsForMutation(record))
publish(readOneTalkRenderedCardFromDom(pageWindow, card));
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-expinfo", "src", "style"],
});
```
Remove `setInterval`, wrapper rescans, React imports, and the deleted reader file.
@@ -74,18 +92,23 @@ node --experimental-strip-types --test apps/chrome-extension/test/onetalk-render
### Task 2: Remove Fiber-only base evidence from local boundaries
**Files:**
- Modify: `apps/chrome-extension/src/onetalk/page-bridge/{main.ts,model.ts}`
- Modify: `apps/chrome-extension/src/onetalk/service-worker/{rendered-card-coordinator.ts,storage.ts}`
- Test: `apps/chrome-extension/test/{onetalk-page-bridge,onetalk-rendered-card-coordinator,onetalk-sync-storage}.test.js`
**Interfaces:**
- `onetalk.page.rendered-card-observed` contains `{ channelAccountId, observations }` only.
- `OneTalkRenderedCardCoordinator.observe(observations)` persists and flushes observations without base evidence.
- [ ] **Step 1: Add bridge and ledger tests that accept an observation-only message and reject legacy base-evidence fields.**
```js
assert.deepEqual(decodeOneTalkPageMessage({ source, version, type, channelAccountId, observations }), expected);
assert.deepEqual(
decodeOneTalkPageMessage({ source, version, type, channelAccountId, observations }),
expected,
);
assert.equal("baseDirection" in pendingRecord, false);
```
@@ -113,12 +136,14 @@ node --experimental-strip-types --test apps/chrome-extension/test/onetalk-page-b
### Task 3: Remove approved server audit columns without data cleanup
**Files:**
- Modify: `apps/server/src/database/schema/onetalk.ts`
- Modify: `apps/server/src/onetalk/rendered-card-{model,repository,service}.ts`
- Create: generated `apps/server/drizzle/0015_*.sql` and matching `drizzle/meta` files
- Test: `apps/server/test/onetalk-rendered-card-{migration,repository,flow}.test.ts`
**Interfaces:**
- Stored row retains key, `renderedCardContent`, `renderedCardContentFingerprint`, `renderedCardObservedAtMs`, and `conflictCount`.
- Same content stays duplicate; different content increments `conflictCount` and returns conflict without overwriting JSON.
@@ -157,6 +182,7 @@ pnpm --filter @trade-message-center/server db:check
### Task 4: Contract-preserving integration verification
**Files:**
- Test: existing contract, read-projection, extension and server suites
- [ ] **Step 1: Add read projection regressions for all three base kinds and reject mismatched supplement kinds.**