diff --git a/docs/superpowers/plans/2026-03-28-wechat-message-forwarding.md b/docs/superpowers/plans/2026-03-28-wechat-message-forwarding.md new file mode 100644 index 0000000..8e550cb --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-wechat-message-forwarding.md @@ -0,0 +1,689 @@ +# Boss 微信式消息转发 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把当前原生 Android 的“备注转发页”重构成微信式消息转发链,支持单条消息转发、多选消息合并转发、统一目标会话选择页,以及服务端 `forwardSource / forwardBundle / approvalRequired` 账本结构。 + +**Architecture:** 保留现有 `BossState -> Next API -> BossApiClient -> 原生活动页` 主链,不引入新基础设施。服务端把 `POST /api/v1/projects/[projectId]/forwards` 从“备注转发”升级成结构化转发接口;原生端在 `ProjectDetailActivity` 内补消息操作菜单、多选状态和目标会话选择页,并以 `ForwardTargetActivity` 承接统一转发目标选择。 + +**Tech Stack:** Next.js App Router, TypeScript, file-backed `data/boss-state.json`, 原生 Android AppCompat + XML, HttpURLConnection, JUnit4 + +--- + +## File Structure + +### Backend / state / API + +- Modify: `src/lib/boss-data.ts` + - 扩展 `MessageKind`、`Message`,增加 `forwardSource`、`forwardBundle` + - 把 `forwardProjectMessage` 升级成支持 `single / bundle / approvalRequired` +- Modify: `src/app/api/v1/projects/[projectId]/forwards/route.ts` + - 校验新的 `single / bundle` 输入结构 + - 返回 `message / approvalRequired / approvalReason` +- Modify: `src/lib/boss-projections.ts` + - 如列表预览或详情聚合需要,补 forwarded message 的预览摘要函数 + +### Android native + +- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java` + - 支持新的 forward payload +- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java` + - 补多选模式、已选消息、转发入口守卫 +- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` + - 单测先行覆盖多选状态切换 +- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java` + - 消息长按菜单、多选模式、跳转目标会话页、forward message 渲染 +- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java` + - 补消息操作菜单、多选勾选 row、聊天记录卡片消息 +- Create: `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java` + - 统一目标会话选择页 +- Create: `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java` + - 目标会话过滤与单选逻辑单测 +- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java` + - 降级为兼容跳转页,直接导向 `ForwardTargetActivity` +- Modify: `android/app/src/main/AndroidManifest.xml` + - 注册 `ForwardTargetActivity` +- Modify: `android/app/src/main/res/layout/activity_project_chat.xml` + - 补多选模式头部 / 底部动作容器 +- Create: `android/app/src/main/res/layout/activity_forward_target.xml` + - 目标会话选择页布局 + +### Docs / release + +- Modify: `README.md` +- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md` +- Modify: `docs/architecture/api_and_service_inventory_cn.md` + +--- + +### Task 1: 升级服务端转发账本和接口结构 + +**Files:** +- Modify: `src/lib/boss-data.ts` +- Modify: `src/app/api/v1/projects/[projectId]/forwards/route.ts` +- Modify: `src/lib/boss-projections.ts` +- Test: `npm run build` + +- [ ] **Step 1: 先把新的消息结构写进 failing contract 注释和类型定义** + +在 `src/lib/boss-data.ts` 的 `MessageKind` 与 `Message` 附近先写出新结构,让后续编译先报缺字段: + +```ts +export type MessageKind = + | "text" + | "voice_intent" + | "image_intent" + | "video_intent" + | "forward_notice" + | "forward_single" + | "forward_bundle"; + +export interface ForwardSource { + sourceProjectId: string; + sourceProjectName: string; + sourceThreadId?: string; + sourceThreadTitle?: string; + sourceMessageId: string; + forwardedBy: string; + forwardedAt: string; +} + +export interface ForwardBundleItem { + messageId: string; + senderLabel: string; + body: string; + kind: string; + sentAt: string; +} + +export interface ForwardBundlePayload { + sourceProjectId: string; + sourceProjectName: string; + sourceThreadId?: string; + sourceThreadTitle?: string; + itemCount: number; + startedAt: string; + endedAt: string; + items: ForwardBundleItem[]; +} + +export interface Message { + id: string; + sender: MessageSender; + senderLabel: string; + body: string; + sentAt: string; + kind?: MessageKind; + forwardSource?: ForwardSource; + forwardBundle?: ForwardBundlePayload; +} +``` + +- [ ] **Step 2: 运行构建,确认当前实现还不支持这些结构** + +Run: + +```bash +cd /Users/kris/code/boss +npm run build +``` + +Expected: 先因为 `forwardProjectMessage` 和相关消息使用点不完整而失败,或者至少需要补 route / render 类型。 + +- [ ] **Step 3: 用最小实现升级 `forwardProjectMessage` 输入结构** + +把 `src/lib/boss-data.ts` 的旧签名: + +```ts +export async function forwardProjectMessage(payload: { + sourceProjectId: string; + targetProjectId: string; + note: string; +}) +``` + +改成: + +```ts +export async function forwardProjectMessage(payload: + | { + sourceProjectId: string; + mode: "single"; + targetProjectId: string; + sourceMessageId: string; + requestedBy: string; + } + | { + sourceProjectId: string; + mode: "bundle"; + targetProjectId: string; + sourceMessageIds: string[]; + requestedBy: string; + } +) {} +``` + +并补 3 个最小 helper: + +```ts +function findProjectMessage(project: Project, messageId: string) {} +function buildForwardSingleMessage(input: { source: Project; target: Project; message: Message; requestedBy: string }) {} +function buildForwardBundleMessage(input: { source: Project; target: Project; messages: Message[]; requestedBy: string }) {} +``` + +最小行为要求: + +- `single` 生成 `kind: "forward_single"`,并带 `forwardSource` +- `bundle` 生成 `kind: "forward_bundle"`,并带 `forwardBundle` +- `target.preview` 更新为新消息正文或卡片摘要 +- `source` 侧继续写一条“已转发到《目标会话》”的轻量日志 + +- [ ] **Step 4: 在 `forwardProjectMessage` 里补审批闸口最小返回** + +在 `src/lib/boss-data.ts` 内先加最小判定: + +```ts +function requiresForwardApproval(source: Project, target: Project) { + return source.collaborationMode === "approval_required" && target.id !== "master-agent"; +} +``` + +并让 `forwardProjectMessage` 在命中审批时返回: + +```ts +return { + approvalRequired: true, + approvalReason: "NON_DEVELOPMENT_THREAD_FORWARD", +}; +``` + +要求: + +- 审批场景下不写入目标消息账本 +- 正常场景才写入目标消息账本并返回 `message` + +- [ ] **Step 5: 升级 route 输入校验** + +在 `src/app/api/v1/projects/[projectId]/forwards/route.ts` 里把旧输入: + +```ts +{ + targetProjectId?: string; + note?: string; +} +``` + +替换成: + +```ts +type ForwardBody = + | { + mode?: "single"; + targetProjectId?: string; + sourceMessageId?: string; + } + | { + mode?: "bundle"; + targetProjectId?: string; + sourceMessageIds?: string[]; + }; +``` + +route 最小逻辑: + +- `mode=single` 时要求 `sourceMessageId` +- `mode=bundle` 时要求 `sourceMessageIds.length > 1` +- 调用 `forwardProjectMessage({ ..., requestedBy: session.account })` +- 返回: + +```ts +return NextResponse.json({ + ok: true, + message: result.message ?? null, + approvalRequired: Boolean(result.approvalRequired), + approvalReason: result.approvalReason ?? null, +}); +``` + +- [ ] **Step 6: 重新构建,确认类型闭合** + +Run: + +```bash +cd /Users/kris/code/boss +npm run build +``` + +Expected: `Compiled successfully` + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/boss-data.ts src/app/api/v1/projects/[projectId]/forwards/route.ts src/lib/boss-projections.ts +git commit -m "feat: add structured message forwarding payloads" +``` + +--- + +### Task 2: 先用单测拉出原生多选转发状态机 + +**Files:** +- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java` +- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` + +- [ ] **Step 1: 先写 failing test,覆盖多选模式切换** + +在 `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` 先补这些测试: + +```java +@Test +public void entersMultiSelectModeAfterFirstToggle() { + ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1"); + assertTrue(state.multiSelecting); + assertEquals(1, state.selectedMessageIds.size()); + assertTrue(state.selectedMessageIds.contains("m1")); +} + +@Test +public void deselectingLastMessageExitsMultiSelectMode() { + ProjectChatUiState.SelectionState state = new ProjectChatUiState.SelectionState(true, java.util.Set.of("m1")); + ProjectChatUiState.SelectionState next = ProjectChatUiState.toggleSelection(state, "m1"); + assertFalse(next.multiSelecting); + assertTrue(next.selectedMessageIds.isEmpty()); +} + +@Test +public void bundleForwardRequiresAtLeastTwoMessages() { + ProjectChatUiState.SelectionState state = new ProjectChatUiState.SelectionState(true, java.util.Set.of("m1")); + assertFalse(ProjectChatUiState.canForwardSelection(state)); +} +``` + +- [ ] **Step 2: 跑单测确认先红** + +Run: + +```bash +cd /Users/kris/code/boss/android +./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon +``` + +Expected: FAIL,提示 `SelectionState` / `toggleSelection` / `canForwardSelection` 尚未实现。 + +- [ ] **Step 3: 在 `ProjectChatUiState.java` 写最小实现** + +补最小状态对象与 helper: + +```java +public static final class SelectionState { + public final boolean multiSelecting; + public final java.util.Set selectedMessageIds; + + public SelectionState(boolean multiSelecting, java.util.Set selectedMessageIds) { + this.multiSelecting = multiSelecting; + this.selectedMessageIds = selectedMessageIds; + } +} + +public static SelectionState emptySelection() { + return new SelectionState(false, new java.util.LinkedHashSet<>()); +} + +public static SelectionState toggleSelection(@Nullable SelectionState current, String messageId) {} + +public static boolean canForwardSelection(@Nullable SelectionState state) { + return state != null && state.selectedMessageIds.size() >= 2; +} +``` + +要求: + +- 第一次 toggle 进入多选 +- 取消最后一条选中后退出多选 +- 保持插入顺序,后面 bundle 卡片会用到 + +- [ ] **Step 4: 跑单测确认转绿** + +Run: + +```bash +cd /Users/kris/code/boss/android +./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java +git commit -m "feat: add native chat forward selection state" +``` + +--- + +### Task 3: 先做会话选择页与 API payload builder,再接聊天页入口 + +**Files:** +- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java` +- Create: `android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java` +- Create: `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java` +- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java` +- Modify: `android/app/src/main/AndroidManifest.xml` +- Create: `android/app/src/main/res/layout/activity_forward_target.xml` + +- [ ] **Step 1: 先写 failing test,覆盖目标会话过滤和单选规则** + +在 `android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java` 先写: + +```java +@Test +public void filtersOutSourceConversationFromTargets() { + JSONArray conversations = new JSONArray() + .put(new StubJSONObject().withString("projectId", "source").withString("projectTitle", "源会话")) + .put(new StubJSONObject().withString("projectId", "target").withString("projectTitle", "目标会话")); + + java.util.List result = ForwardTargetActivity.collectSelectableTargets(conversations, "source"); + + assertEquals(1, result.size()); + assertEquals("target", result.get(0).optString("projectId")); +} + +@Test +public void singleModeRequiresOneMessageId() throws Exception { + JSONObject payload = ForwardTargetActivity.buildForwardPayload("single", "m1", java.util.List.of()); + assertEquals("single", payload.optString("mode")); + assertEquals("m1", payload.optString("sourceMessageId")); +} + +@Test +public void bundleModeUsesOrderedMessageIds() throws Exception { + JSONObject payload = ForwardTargetActivity.buildForwardPayload("bundle", null, java.util.List.of("m1", "m2")); + assertEquals("bundle", payload.optString("mode")); + assertEquals(2, payload.optJSONArray("sourceMessageIds").length()); +} +``` + +- [ ] **Step 2: 跑单测确认先红** + +Run: + +```bash +cd /Users/kris/code/boss/android +./gradlew testDebugUnitTest --tests com.hyzq.boss.ForwardTargetActivityTest --no-daemon +``` + +Expected: FAIL,提示 `ForwardTargetActivity` helper 未实现。 + +- [ ] **Step 3: 在 `BossApiClient.java` 补结构化转发方法** + +把旧方法: + +```java +public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, String note) +``` + +替换为: + +```java +public ApiResponse forwardProjectMessage(String projectId, String targetProjectId, JSONObject payload) +``` + +方法内最小逻辑: + +```java +JSONObject requestPayload = payload == null ? new JSONObject() : payload; +requestPayload.put("targetProjectId", targetProjectId); +return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/forwards", requestPayload); +``` + +- [ ] **Step 4: 写 `ForwardTargetActivity` 最小实现** + +活动页至少需要: + +```java +public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id"; +public static final String EXTRA_FORWARD_MODE = "forward_mode"; +public static final String EXTRA_SOURCE_MESSAGE_ID = "source_message_id"; +public static final String EXTRA_SOURCE_MESSAGE_IDS = "source_message_ids"; + +static java.util.List collectSelectableTargets(JSONArray conversations, String sourceProjectId) {} +static JSONObject buildForwardPayload(String mode, @Nullable String sourceMessageId, java.util.List sourceMessageIds) throws JSONException {} +``` + +页面行为最小版: + +- 拉取 `apiClient.getConversations()` +- 过滤源会话 +- 列出微信式会话 cell +- 点中某个目标会话后调用新的 `forwardProjectMessage` +- `approvalRequired=true` 时先提示“已提交主 Agent 审批” +- 正常成功时 `setResult(RESULT_OK)` 后 finish + +- [ ] **Step 5: 让旧 `ProjectForwardActivity` 只做兼容跳转** + +在 `android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java` 中删除旧备注输入主链,保留: + +```java +Intent intent = new Intent(this, ForwardTargetActivity.class); +intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId); +intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single_legacy"); +startActivity(intent); +finish(); +``` + +并把标题副文案改成“正在切换到微信式转发”。 + +- [ ] **Step 6: 跑单测确认转绿** + +Run: + +```bash +cd /Users/kris/code/boss/android +./gradlew testDebugUnitTest --tests com.hyzq.boss.ForwardTargetActivityTest --no-daemon +``` + +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add android/app/src/main/java/com/hyzq/boss/BossApiClient.java android/app/src/main/java/com/hyzq/boss/ForwardTargetActivity.java android/app/src/main/java/com/hyzq/boss/ProjectForwardActivity.java android/app/src/main/AndroidManifest.xml android/app/src/main/res/layout/activity_forward_target.xml android/app/src/test/java/com/hyzq/boss/ForwardTargetActivityTest.java +git commit -m "feat: add native forward target picker" +``` + +--- + +### Task 4: 把消息长按、多选和转发结果真正接进聊天页 + +**Files:** +- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java` +- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java` +- Modify: `android/app/src/main/res/layout/activity_project_chat.xml` +- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java` +- Modify: `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` + +- [ ] **Step 1: 先写 failing test,覆盖 forward kind 的 UI 标签** + +先在 `android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java` 追加: + +```java +@Test +public void singleForwardMessageUsesSingleModeLabel() { + assertEquals("转发", ProjectChatUiState.labelForForwardKind("forward_single")); +} + +@Test +public void bundleForwardMessageUsesBundleModeLabel() { + assertEquals("聊天记录", ProjectChatUiState.labelForForwardKind("forward_bundle")); +} +``` + +- [ ] **Step 2: 跑单测确认先红** + +Run: + +```bash +cd /Users/kris/code/boss/android +./gradlew testDebugUnitTest --tests com.hyzq.boss.ProjectChatUiStateTest --no-daemon +``` + +Expected: FAIL,提示 `labelForForwardKind` 未定义。 + +- [ ] **Step 3: 在 `BossUi.java` 增加转发消息和聊天记录卡片** + +新增两个 builder: + +```java +public static LinearLayout buildForwardSingleBubble( + Context context, + String senderLabel, + String body, + @Nullable String meta, + @Nullable String sourceLabel, + boolean outgoing +) {} + +public static LinearLayout buildForwardBundleCard( + Context context, + String senderLabel, + String cardTitle, + String summary, + @Nullable String meta, + boolean outgoing +) {} +``` + +要求: + +- `forward_single` 仍看起来像普通消息 bubble +- `forward_bundle` 明显是聊天记录卡片,但不能长成控制台卡片 + +- [ ] **Step 4: 在 `ProjectDetailActivity.java` 接入长按与多选** + +最小实现顺序: + +1. 为每条消息 view 绑定 `messageId` +2. 长按消息时弹出原生 `AlertDialog` 操作菜单:`转发 / 多选 / 复制 / 删除 / 取消` +3. `转发` 时直接打开 `ForwardTargetActivity` +4. `多选` 时切换 `SelectionState` +5. 多选模式下顶部切为 `取消 + 已选数量` +6. 底部输入区切换为单按钮 `转发` + +关键入口: + +```java +private void openSingleForwardTarget(String sourceMessageId) {} +private void openBundleForwardTarget(java.util.List sourceMessageIds) {} +private void enterMultiSelectFromMessage(String messageId) {} +private void exitMultiSelect() {} +``` + +- [ ] **Step 5: 在消息渲染分支中接入新 kind** + +把现有 `labelForMessageKind(...)` 和消息渲染分支补成: + +```java +case "forward_single": + return BossUi.buildForwardSingleBubble(...); +case "forward_bundle": + return BossUi.buildForwardBundleCard(...); +``` + +并让 `ProjectChatUiState.labelForForwardKind(...)` 提供: + +```java +"forward_single" -> "转发" +"forward_bundle" -> "聊天记录" +``` + +- [ ] **Step 6: 跑 Android 编译和单测** + +Run: + +```bash +cd /Users/kris/code/boss/android +./gradlew testDebugUnitTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon +``` + +Expected: `BUILD SUCCESSFUL` + +- [ ] **Step 7: 跑 Web 构建与接口烟测** + +Run: + +```bash +cd /Users/kris/code/boss +npm run lint +npm run build +npm start +curl -sS http://127.0.0.1:3000/api/health +curl -sS -H 'Content-Type: application/json' -d '{"mode":"single","targetProjectId":"master-agent","sourceMessageId":"m-test"}' http://127.0.0.1:3000/api/v1/projects/boss-console-ui/forwards +``` + +Expected: + +- `lint` 通过 +- `build` 通过 +- `/api/health` 返回 `{ ok: true }` +- `/forwards` 返回结构化 JSON,包含 `message` 或 `approvalRequired` + +- [ ] **Step 8: Commit** + +```bash +git add android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java android/app/src/main/java/com/hyzq/boss/BossUi.java android/app/src/main/res/layout/activity_project_chat.xml android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java android/app/src/test/java/com/hyzq/boss/ProjectChatUiStateTest.java +git commit -m "feat: add wechat style native message forwarding" +``` + +--- + +### Task 5: 文档、发布和完整验证 + +**Files:** +- Modify: `README.md` +- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md` +- Modify: `docs/architecture/api_and_service_inventory_cn.md` + +- [ ] **Step 1: 同步文档** + +把以下事实写回文档: + +- 原生 Android 已支持单条消息转发 +- 原生 Android 已支持多选合并转发 +- 新增 `ForwardTargetActivity` +- `POST /api/v1/projects/[projectId]/forwards` 已支持 `single / bundle` +- 单条消息落 `forwardSource` +- 多条消息落 `forwardBundle` +- 审批闸口已预留 + +- [ ] **Step 2: 完整验证** + +Run: + +```bash +cd /Users/kris/code/boss +npm run lint +npm run build +curl -sS http://127.0.0.1:3000/api/health +curl -sS http://127.0.0.1:4317/health +cd android && ./gradlew testDebugUnitTest :app:compileDebugJavaWithJavac assembleDebug --no-daemon +cd /Users/kris/code/boss +JAVA_HOME=$(/usr/libexec/java_home) npm run apk:release +JAVA_HOME=$(/usr/libexec/java_home) npm run aab:release +./scripts/deploy-server.sh +"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "curl -sS http://127.0.0.1:3000/api/health" +curl -sS https://boss.hyzq.net/api/health +``` + +Expected: + +- 全部成功 +- 公网元数据刷新到新版本 + +- [ ] **Step 3: Commit** + +```bash +git add README.md docs/architecture/current_runtime_and_deploy_status_cn.md docs/architecture/api_and_service_inventory_cn.md +git commit -m "docs: update forwarding architecture and runtime status" +``` +