mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
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:
@@ -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.**
|
||||
|
||||
Reference in New Issue
Block a user