Skip to content

feat(ai): draft lifecycle - send/discard/view, retention, fetch timeout+retry#16

Merged
one-ea merged 1 commit into
mainfrom
260629-feat-ai-draft-lifecycle
Jun 29, 2026
Merged

feat(ai): draft lifecycle - send/discard/view, retention, fetch timeout+retry#16
one-ea merged 1 commit into
mainfrom
260629-feat-ai-draft-lifecycle

Conversation

@one-ea

@one-ea one-ea commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add /draft send — creates Delivery record, sendMessage to external user, marks draft as sent on success
  • Add /draft discard — marks latest ready draft as discarded
  • Add /draft view — shows latest ready draft without regenerating (saves AI calls)
  • /draft (no args) keeps existing behavior (regenerate)
  • Add AiDraftService.findReady, markSent, markDiscarded methods
  • Extend draft status with sent and discarded values (no schema migration needed)
  • AI fetch: 15s timeout (AbortSignal.timeout) + 1 retry on 5xx/timeout; 4xx skips retry
  • Truncate draft text to 4000 chars; limit AI context to 12000 chars (drop oldest messages)
  • RetentionService now cleans ai_drafts:
    • Stale pending (>5min) recovered as failed with timeout error message
    • Terminal drafts (sent/discarded/failed) hard-deleted past retention period
    • Active drafts soft-cleaned (null out draft_text/error, keep row for audit)
  • Update help text and Telegram menu description for /draft subcommands
  • Add DeliveryService.stats() for status aggregation
  • 5 new test cases

Problem

Drafts had no send/discard/view commands — admins had to copy-paste from Telegram. ai_drafts table grew indefinitely with no cleanup. AI fetch had no timeout, so slow AI APIs blocked message processing. Crashed pending drafts stayed pending forever.

Verification

  • npm run verify passed (check + 30 tests + audit 0 vulns)

Summary by CodeRabbit

  • 新功能

    • Telegram 草稿指令支持更多操作:可查看草稿、发送草稿或丢弃草稿。
    • 草稿发送后会有明确反馈,并保留失败后重试的能力。
    • 保留清理现在会自动处理超时未完成的草稿,并按配置保留历史内容。
  • Bug 修复

    • 改进了草稿生成与发送流程的稳定性,减少空内容或失败状态带来的异常情况。
    • 优化了过期内容清理统计,结果更准确。

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

AiDraftService 中新增 DraftRow 接口及 findReady/markSent/markDiscarded 方法,并重构 generate 加入重试与超时。/draft 命令扩展为 view/send/discard 子命令,通过 DeliveryService 跟踪投递状态。RetentionService 新增 retentionDays/logger 参数并扩展 ai_drafts 清理逻辑,相关配置传递同步更新。

Changes

AI 草稿生命周期与投递流程

Layer / File(s) Summary
AiDraftService 数据类型与新增方法
src/domain/ai-drafts.ts
新增 DraftRow 接口、buildContext/draftFromRow 内部辅助函数;重构 generate 引入 AbortSignal.timeout 与最多 2 次重试循环;新增 findReady/markSent/markDiscarded 公共方法。
/draft 子命令处理与 CommandDeps 扩展
src/channels/telegram/commands.ts, src/channels/telegram/menu.ts
CommandDeps 新增 deliveries: DeliveryService 字段;handleTopicCommanddraft 分支按子命令分流为 send(创建投递、发送、更新状态)、discard(标记丢弃)、view(仅回显);菜单描述同步更新为 view|send|discard
RetentionService 扩展与配置传递
src/domain/retention.ts, src/runtime/main.ts, src/tools/retention-cleanup.ts
构造函数新增 retentionDays 与可选 logger 参数;cleanupExpired 扩展为:将超时 pending 草稿标记为 failed,按保留期对终态草稿执行硬删除与软清理,累计返回总变更数;main.tsretention-cleanup.ts 同步传入配置参数。
AI 草稿生命周期测试
test/core.test.ts
新增 describe("AI draft lifecycle") 覆盖 findReady/markSent/markDiscarded 行为、陈旧 pending 草稿恢复为 failed、保留期硬删除,以及 DeliveryService.stats() 聚合统计;更新已有测试的 RetentionService 构造参数。

Sequence Diagram(s)

sequenceDiagram
  participant Admin as 管理员 Telegram
  participant handleTopicCommand
  participant AiDraftService
  participant DeliveryService
  participant ExternalUser as 外部用户 Telegram

  rect rgba(70, 130, 180, 0.5)
    note over Admin,ExternalUser: /draft send 流程
    Admin->>handleTopicCommand: /draft send
    handleTopicCommand->>AiDraftService: findReady(conversationId)
    AiDraftService-->>handleTopicCommand: DraftRow
    handleTopicCommand->>DeliveryService: create(pending)
    handleTopicCommand->>ExternalUser: sendMessage(draftText)
    alt 发送成功
      handleTopicCommand->>DeliveryService: markSent(deliveryId)
      handleTopicCommand->>AiDraftService: markSent(draftId)
    else 发送失败
      handleTopicCommand->>DeliveryService: markFailed(deliveryId, error)
      handleTopicCommand-->>Admin: 发送失败,可重试 /draft send
    end
  end

  rect rgba(60, 179, 113, 0.5)
    note over Admin,AiDraftService: /draft discard / view 流程
    Admin->>handleTopicCommand: /draft discard
    handleTopicCommand->>AiDraftService: findReady(conversationId)
    handleTopicCommand->>AiDraftService: markDiscarded(draftId)
    handleTopicCommand-->>Admin: 草稿已丢弃

    Admin->>handleTopicCommand: /draft view
    handleTopicCommand->>AiDraftService: findReady(conversationId)
    handleTopicCommand-->>Admin: 回显草稿文本
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 标题与变更集高度相关,概括了草稿生命周期、保留清理以及抓取超时重试等主要改动。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 260629-feat-ai-draft-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/channels/telegram/commands.ts`:
- Around line 307-327: In the `subCommand === "send"` flow of `commands.ts`, the
draft send path skips the existing blocklist guard used by the normal outbound
path. Reuse the same `isBlocked(contact.id)` check from the Telegram outbound
handling in `messages.ts` before calling `ctx.api.sendMessage`, and return the
same blocked-user reply so `/draft send` cannot bypass `/ban`.
- Around line 314-323: In the Telegram send flow inside the command handler that
uses ctx.api.sendMessage, persist a new outbound text message before creating
the delivery record instead of reusing draft.sourceMessageId. Update the logic
around deps.deliveries.createPending, markSent, and markFailed so the delivery
is tied to the newly created message id, matching the standard flow used in
telegram/messages.ts. Keep deps.aiDrafts.markSent after a successful send, but
ensure the outbound message is written first so history, export, and AI context
include the real reply.

In `@src/domain/ai-drafts.ts`:
- Around line 167-188: The current DraftRow workflow in ai-drafts.ts is still a
non-atomic read-then-write sequence, so concurrent `/draft send` calls can both
consume the same ready draft. Replace the split `findReady()` +
`markSent()`/`markDiscarded()` flow with a single compare-and-swap style claim
operation on `ai_drafts` that atomically changes `ready` to a temporary
in-flight state (for example `sending`) and returns success only for the winner;
then complete with `sent` or roll back to a terminal failure state on error.
Keep `findReady`, `markSent`, and `markDiscarded` aligned with the new state
machine so duplicate sends are prevented under concurrent access.
- Around line 124-149: 4xx errors are still being retried in the AI draft fetch
flow. In the retry loop inside ai-drafts.ts, update the catch path around the
fetch/parse logic so the thrown 4xx error from the response handling is
recognized as non-retryable and immediately rethrown or exits without waiting
for AI_FETCH_RETRY_DELAY_MS. Use the existing draft-fetch retry code in the same
block where lastError, attempts, and response.status are handled to ensure only
transient failures retry while AI provider 4xx responses fail fast.

In `@src/domain/retention.ts`:
- Around line 14-18: `cleanupExpired` is mixing two time sources, so all cutoff
timestamps should be derived from the same `now` argument. Update
`RetentionService.cleanupExpired` to compute both `staleCutoff` and
`retentionCutoff` from the passed-in `now` value instead of `Date.now()`,
keeping `ai_drafts` and `messages` cleanup on a consistent time basis. Use the
existing `nowIso`/`cleanupExpired` flow as the reference point and keep the
cutoff calculations aligned throughout the method.
- Around line 41-47: The retention cleanup in retention.ts is clearing
draft_text/error for records that can still be selected by AiDrafts.findReady()
because they remain in status = 'ready'. Update the soft cleanup query to
exclude ready drafts, or if ready drafts must be anonymized, also transition
them to a terminal status so /draft view and /draft send cannot fetch a
visible-but-empty draft. Use the existing retention cleanup path and the
findReady() behavior in ai-drafts.ts to keep the lifecycle consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 041d3bfa-98b6-4f87-884f-ad312e150a46

📥 Commits

Reviewing files that changed from the base of the PR and between 8969361 and cde1313.

📒 Files selected for processing (8)
  • src/channels/telegram/commands.ts
  • src/channels/telegram/menu.ts
  • src/domain/ai-drafts.ts
  • src/domain/deliveries.ts
  • src/domain/retention.ts
  • src/runtime/main.ts
  • src/tools/retention-cleanup.ts
  • test/core.test.ts

Comment on lines +307 to +327
if (subCommand === "send") {
const draft = deps.aiDrafts.findReady(conversation.id);
if (!draft || !draft.draftText) {
await ctx.reply("当前没有可发送的草稿,使用 /draft 生成。");
return true;
}
try {
const deliveryId = await deps.deliveries.createPending(draft.sourceMessageId ?? undefined, `telegram-user:${contact.externalUserId}`);
try {
await ctx.api.sendMessage(Number(contact.externalUserId), draft.draftText);
await deps.deliveries.markSent(deliveryId);
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
await deps.deliveries.markFailed(deliveryId, errMsg, 3);
throw error;
}
deps.aiDrafts.markSent(draft.id);
await ctx.reply("草稿已发送给外部用户。");
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
await ctx.reply(`草稿发送失败:${msg}。草稿保留,可重试 /draft send。`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

/draft send 需要复用现有的封禁检查。

普通外发路径在 src/channels/telegram/messages.ts:300-340 里会先拦截 isBlocked(contact.id);这里直接对 contact.externalUserId 执行 sendMessage,会让 /ban 对草稿发送失效,管理员仍可向已封禁联系人外发消息。发送前应复用同一套封禁判断,并返回与普通回复一致的提示。
As per path instructions, src/**/*.ts 重点审查 Telegram 双向转发、封禁和数据库写入逻辑。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/channels/telegram/commands.ts` around lines 307 - 327, In the `subCommand
=== "send"` flow of `commands.ts`, the draft send path skips the existing
blocklist guard used by the normal outbound path. Reuse the same
`isBlocked(contact.id)` check from the Telegram outbound handling in
`messages.ts` before calling `ctx.api.sendMessage`, and return the same
blocked-user reply so `/draft send` cannot bypass `/ban`.

Source: Path instructions

Comment on lines +314 to +323
const deliveryId = await deps.deliveries.createPending(draft.sourceMessageId ?? undefined, `telegram-user:${contact.externalUserId}`);
try {
await ctx.api.sendMessage(Number(contact.externalUserId), draft.draftText);
await deps.deliveries.markSent(deliveryId);
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error);
await deps.deliveries.markFailed(deliveryId, errMsg, 3);
throw error;
}
deps.aiDrafts.markSent(draft.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

发送成功后缺少 outbound message 落库。

常规外发流程在 src/channels/telegram/messages.ts:300-340 会先 createMessage(...),再用新消息 id 创建 delivery。这里直接拿 draft.sourceMessageId 建 delivery 并调用 sendMessage,没有写入新的 outbound message 记录。结果是历史/导出/后续 AI 上下文都会缺少这条真实回复,而且 delivery 会错误关联到触发草稿生成的旧消息。发送前应先持久化一条新的 outbound text message,再把它的 id 传给 createPending()
As per path instructions, src/**/*.ts 重点审查 Telegram 双向转发和数据库写入逻辑。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/channels/telegram/commands.ts` around lines 314 - 323, In the Telegram
send flow inside the command handler that uses ctx.api.sendMessage, persist a
new outbound text message before creating the delivery record instead of reusing
draft.sourceMessageId. Update the logic around deps.deliveries.createPending,
markSent, and markFailed so the delivery is tied to the newly created message
id, matching the standard flow used in telegram/messages.ts. Keep
deps.aiDrafts.markSent after a successful send, but ensure the outbound message
is written first so history, export, and AI context include the real reply.

Source: Path instructions

Comment thread src/domain/ai-drafts.ts
Comment on lines +124 to +149
if (response.status >= 400 && response.status < 500) {
throw new Error(`AI provider returned HTTP ${response.status}`);
}

if (!response.ok) {
throw new Error(`AI provider returned HTTP ${response.status}`);
}

const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
const rawText = data.choices?.[0]?.message?.content?.trim();
if (!rawText) {
throw new Error("AI provider returned an empty draft.");
}

const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
const text = data.choices?.[0]?.message?.content?.trim();
if (!text) {
throw new Error("AI provider returned an empty draft.");
const text = truncateText(rawText);
this.db
.prepare("UPDATE ai_drafts SET status = 'ready', draft_text = ?, updated_at = ? WHERE id = ?")
.run(text, nowIso(), draftId);
const elapsed = Date.now() - startTime;
void elapsed;
return { status: "ready", text };
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempts < 2) {
await new Promise((resolve) => setTimeout(resolve, AI_FETCH_RETRY_DELAY_MS));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

4xx 失败现在仍会进入重试分支。

Line 124-125 已把 4xx 当成不可恢复错误抛出,但 Line 145-149 的 catch 没有区分错误类型,所以鉴权/参数类失败依然会再等 2 秒重试一次。这和本 PR“4xx 不重试”的目标不一致,也会平白消耗一次 provider 调用。

建议修复
+class NonRetryableAiError extends Error {}
+
       while (attempts < 2) {
         attempts += 1;
         try {
           const response = await fetch(url, {
             method: "POST",
@@
-          if (response.status >= 400 && response.status < 500) {
-            throw new Error(`AI provider returned HTTP ${response.status}`);
+          if (response.status >= 400 && response.status < 500) {
+            throw new NonRetryableAiError(`AI provider returned HTTP ${response.status}`);
           }
@@
         } catch (error) {
           lastError = error instanceof Error ? error : new Error(String(error));
-          if (attempts < 2) {
+          if (!(lastError instanceof NonRetryableAiError) && attempts < 2) {
             await new Promise((resolve) => setTimeout(resolve, AI_FETCH_RETRY_DELAY_MS));
           }
         }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (response.status >= 400 && response.status < 500) {
throw new Error(`AI provider returned HTTP ${response.status}`);
}
if (!response.ok) {
throw new Error(`AI provider returned HTTP ${response.status}`);
}
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
const rawText = data.choices?.[0]?.message?.content?.trim();
if (!rawText) {
throw new Error("AI provider returned an empty draft.");
}
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
const text = data.choices?.[0]?.message?.content?.trim();
if (!text) {
throw new Error("AI provider returned an empty draft.");
const text = truncateText(rawText);
this.db
.prepare("UPDATE ai_drafts SET status = 'ready', draft_text = ?, updated_at = ? WHERE id = ?")
.run(text, nowIso(), draftId);
const elapsed = Date.now() - startTime;
void elapsed;
return { status: "ready", text };
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempts < 2) {
await new Promise((resolve) => setTimeout(resolve, AI_FETCH_RETRY_DELAY_MS));
}
class NonRetryableAiError extends Error {}
if (response.status >= 400 && response.status < 500) {
throw new NonRetryableAiError(`AI provider returned HTTP ${response.status}`);
}
if (!response.ok) {
throw new Error(`AI provider returned HTTP ${response.status}`);
}
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
const rawText = data.choices?.[0]?.message?.content?.trim();
if (!rawText) {
throw new Error("AI provider returned an empty draft.");
}
const text = truncateText(rawText);
this.db
.prepare("UPDATE ai_drafts SET status = 'ready', draft_text = ?, updated_at = ? WHERE id = ?")
.run(text, nowIso(), draftId);
const elapsed = Date.now() - startTime;
void elapsed;
return { status: "ready", text };
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (!(lastError instanceof NonRetryableAiError) && attempts < 2) {
await new Promise((resolve) => setTimeout(resolve, AI_FETCH_RETRY_DELAY_MS));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/ai-drafts.ts` around lines 124 - 149, 4xx errors are still being
retried in the AI draft fetch flow. In the retry loop inside ai-drafts.ts,
update the catch path around the fetch/parse logic so the thrown 4xx error from
the response handling is recognized as non-retryable and immediately rethrown or
exits without waiting for AI_FETCH_RETRY_DELAY_MS. Use the existing draft-fetch
retry code in the same block where lastError, attempts, and response.status are
handled to ensure only transient failures retry while AI provider 4xx responses
fail fast.

Comment thread src/domain/ai-drafts.ts
Comment on lines +167 to +188
findReady(conversationId: number): DraftRow | undefined {
const row = this.db
.prepare(
`SELECT * FROM ai_drafts
WHERE conversation_id = ? AND status = 'ready'
ORDER BY created_at DESC LIMIT 1`,
)
.get(conversationId) as Record<string, unknown> | undefined;
return row ? draftFromRow(row) : undefined;
}

markSent(draftId: number): void {
this.db
.prepare("UPDATE ai_drafts SET status = 'sent', updated_at = ? WHERE id = ?")
.run(nowIso(), draftId);
}

markDiscarded(draftId: number): void {
this.db
.prepare("UPDATE ai_drafts SET status = 'discarded', updated_at = ? WHERE id = ?")
.run(nowIso(), draftId);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

给草稿发送增加原子“领取”步骤。

当前新增 API 仍然要求调用方先 findReady(),再单独 markSent()/markDiscarded()。在共享 Topic 里,两个管理员几乎同时执行 /draft send 时,都能读到同一条 ready 草稿,并在任一状态更新前各自发出一次消息,导致重复外发。这里需要 compare-and-swap 式的领取流程(例如先把 ready 原子改成临时 sending,成功后转 sent,失败时回滚/置 failed),不能继续依赖“先读后写”的两步流。
As per path instructions, src/**/*.ts 需要重点审查 Telegram 双向转发和数据库写入逻辑。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/ai-drafts.ts` around lines 167 - 188, The current DraftRow
workflow in ai-drafts.ts is still a non-atomic read-then-write sequence, so
concurrent `/draft send` calls can both consume the same ready draft. Replace
the split `findReady()` + `markSent()`/`markDiscarded()` flow with a single
compare-and-swap style claim operation on `ai_drafts` that atomically changes
`ready` to a temporary in-flight state (for example `sending`) and returns
success only for the winner; then complete with `sent` or roll back to a
terminal failure state on error. Keep `findReady`, `markSent`, and
`markDiscarded` aligned with the new state machine so duplicate sends are
prevented under concurrent access.

Source: Path instructions

Comment thread src/domain/retention.ts
Comment on lines 14 to +18
async cleanupExpired(now = nowIso()): Promise<number> {
let cleaned = 0;

const staleCutoff = new Date(Date.now() - STALE_PENDING_THRESHOLD_MS).toISOString();
const staleResult = this.db

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

请统一用传入的 now 计算所有截止时间。

staleCutoff(Line 17)和 retentionCutoff(Line 31)现在都基于 Date.now(),但后面的 messages 清理走的是 now 参数。这样 cleanupExpired("2999-01-01T00:00:00.000Z") 之类的显式时间只会影响一半逻辑,同一次清理会对 ai_draftsmessages 使用不同时间基准,结果不一致。

建议修改
 async cleanupExpired(now = nowIso()): Promise<number> {
   let cleaned = 0;
+  const nowMs = new Date(now).getTime();

-  const staleCutoff = new Date(Date.now() - STALE_PENDING_THRESHOLD_MS).toISOString();
+  const staleCutoff = new Date(nowMs - STALE_PENDING_THRESHOLD_MS).toISOString();
   const staleResult = this.db
     .prepare(
       `UPDATE ai_drafts
        SET status = 'failed', error = 'Draft generation timed out (process may have restarted)', updated_at = ?
        WHERE status = 'pending' AND created_at < ?`,
     )
     .run(now, staleCutoff);

   if (this.retentionDays > 0) {
-    const retentionCutoff = new Date(Date.now() - this.retentionDays * 86400 * 1000).toISOString();
+    const retentionCutoff = new Date(nowMs - this.retentionDays * 86400 * 1000).toISOString();

Also applies to: 30-31

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/retention.ts` around lines 14 - 18, `cleanupExpired` is mixing two
time sources, so all cutoff timestamps should be derived from the same `now`
argument. Update `RetentionService.cleanupExpired` to compute both `staleCutoff`
and `retentionCutoff` from the passed-in `now` value instead of `Date.now()`,
keeping `ai_drafts` and `messages` cleanup on a consistent time basis. Use the
existing `nowIso`/`cleanupExpired` flow as the reference point and keep the
cutoff calculations aligned throughout the method.

Comment thread src/domain/retention.ts
Comment on lines +41 to +47
const softCleaned = this.db
.prepare(
`UPDATE ai_drafts
SET draft_text = NULL, error = NULL, updated_at = ?
WHERE created_at < ? AND (draft_text IS NOT NULL OR error IS NOT NULL)`,
)
.run(now, retentionCutoff);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

不要保留 ready 状态却把草稿内容清空。

这里会匹配所有超期且有 draft_text / error 的草稿,包括 status = 'ready'。但 src/domain/ai-drafts.ts 里的 findReady() 仍然只按 status = 'ready' 取最新记录,所以 /draft view / /draft send 会有机会拿到一个“可见但无内容”的草稿。最小修复是排除 ready;如果产品上必须脱敏 ready,这里需要同步把状态转成终态,避免后续继续把它当可发送草稿。

最小安全修复示例
       const softCleaned = this.db
         .prepare(
           `UPDATE ai_drafts
            SET draft_text = NULL, error = NULL, updated_at = ?
-           WHERE created_at < ? AND (draft_text IS NOT NULL OR error IS NOT NULL)`,
+           WHERE created_at < ?
+             AND status <> 'ready'
+             AND (draft_text IS NOT NULL OR error IS NOT NULL)`,
         )
         .run(now, retentionCutoff);

As per path instructions, "重点审查 Telegram 双向转发、管理员白名单、限流、封禁、会话销毁和数据库写入逻辑。任何可能导致非白名单成员代发、bot 自己消息回环、Topic 错路由或用户数据泄露的问题都应标为高优先级。"

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const softCleaned = this.db
.prepare(
`UPDATE ai_drafts
SET draft_text = NULL, error = NULL, updated_at = ?
WHERE created_at < ? AND (draft_text IS NOT NULL OR error IS NOT NULL)`,
)
.run(now, retentionCutoff);
const softCleaned = this.db
.prepare(
`UPDATE ai_drafts
SET draft_text = NULL, error = NULL, updated_at = ?
WHERE created_at < ?
AND status <> 'ready'
AND (draft_text IS NOT NULL OR error IS NOT NULL)`,
)
.run(now, retentionCutoff);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/retention.ts` around lines 41 - 47, The retention cleanup in
retention.ts is clearing draft_text/error for records that can still be selected
by AiDrafts.findReady() because they remain in status = 'ready'. Update the soft
cleanup query to exclude ready drafts, or if ready drafts must be anonymized,
also transition them to a terminal status so /draft view and /draft send cannot
fetch a visible-but-empty draft. Use the existing retention cleanup path and the
findReady() behavior in ai-drafts.ts to keep the lifecycle consistent.

Source: Path instructions

…imeout+retry

- Add /draft send (creates Delivery record, sendMessage to external user,
  marks draft as sent on success)
- Add /draft discard (marks latest ready draft as discarded)
- Add /draft view (shows latest ready draft without regenerating)
- Add AiDraftService.findReady, markSent, markDiscard methods
- Extend draft status with 'sent' and 'discarded' values
- Add AI fetch 15s timeout (AbortSignal.timeout) + 1 retry on 5xx/timeout
- 4xx errors skip retry, immediately mark as failed
- Truncate draft text to 4000 chars, limit AI context to 12000 chars
- RetentionService now cleans ai_drafts:
  - Stale pending (>5min) recovered as failed
  - Terminal drafts (sent/discarded/failed) hard-deleted past retention
  - Active drafts soft-cleaned (null out text/error, keep row)
- Update help text and menu description for /draft subcommands
- Add DeliveryService.stats() for status aggregation
- 5 new test cases covering findReady, markSent/markDiscarded,
  stale pending recovery, terminal draft deletion, delivery stats
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>

Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
@monkeycode-ai monkeycode-ai Bot force-pushed the 260629-feat-ai-draft-lifecycle branch from cde1313 to 01d1646 Compare June 29, 2026 19:14
@one-ea one-ea merged commit ed2364a into main Jun 29, 2026
3 of 4 checks passed
@one-ea one-ea deleted the 260629-feat-ai-draft-lifecycle branch June 29, 2026 19:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/core.test.ts`:
- Around line 711-747: The test for AiDraftService currently only checks that
findReady() stops returning a draft, which would still pass if markSent() and
markDiscarded() accidentally set the same status. Update this test to assert the
persisted status values after calling AiDraftService.markSent() and
AiDraftService.markDiscarded(), using the draft records created via the
ai_drafts insert and the draft IDs returned by findReady() so the new state
semantics are explicitly covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a77a1bdb-4f7b-46c7-b07e-ea456b172de9

📥 Commits

Reviewing files that changed from the base of the PR and between cde1313 and 01d1646.

📒 Files selected for processing (7)
  • src/channels/telegram/commands.ts
  • src/channels/telegram/menu.ts
  • src/domain/ai-drafts.ts
  • src/domain/retention.ts
  • src/runtime/main.ts
  • src/tools/retention-cleanup.ts
  • test/core.test.ts

Comment thread test/core.test.ts
Comment on lines +711 to +747
it("marks draft as sent and discarded", async () => {
const conversations = new ConversationService(handle.db, 30);
const config = loadConfig({
TELEGRAM_BOT_TOKEN: "token",
TELEGRAM_MANAGEMENT_CHAT_ID: "-1001",
TELEGRAM_UPDATE_MODE: "polling",
TELEGRAM_ADMIN_USER_IDS: "1",
});
const aiDrafts = new AiDraftService(handle.db, conversations, config);
const bundle = await conversations.getOrCreateConversation({
platform: "telegram",
externalUserId: "123",
displayName: "Test",
});

handle.db
.prepare(
`INSERT INTO ai_drafts (conversation_id, status, draft_text, created_at, updated_at)
VALUES (?, 'ready', 'hello', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
)
.run(bundle.conversation.id);
const draft = aiDrafts.findReady(bundle.conversation.id);
assert.ok(draft);

aiDrafts.markSent(draft.id);
assert.equal(aiDrafts.findReady(bundle.conversation.id), undefined);

handle.db
.prepare(
`INSERT INTO ai_drafts (conversation_id, status, draft_text, created_at, updated_at)
VALUES (?, 'ready', 'world', '2026-01-02T00:00:00Z', '2026-01-02T00:00:00Z')`,
)
.run(bundle.conversation.id);
const draft2 = aiDrafts.findReady(bundle.conversation.id);
assert.ok(draft2);
aiDrafts.markDiscarded(draft2.id);
assert.equal(aiDrafts.findReady(bundle.conversation.id), undefined);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

补上对持久化状态值的断言。

这里现在只验证 findReady() 不再返回草稿;如果 markSent()markDiscarded() 都误写成同一个终态,这个用例仍然会通过,覆盖不到本 PR 新增的状态语义。

建议补丁
     aiDrafts.markSent(draft.id);
     assert.equal(aiDrafts.findReady(bundle.conversation.id), undefined);
+    const sentRow = handle.db
+      .prepare("SELECT status FROM ai_drafts WHERE id = ?")
+      .get(draft.id) as { status: string };
+    assert.equal(sentRow.status, "sent");

     handle.db
       .prepare(
         `INSERT INTO ai_drafts (conversation_id, status, draft_text, created_at, updated_at)
          VALUES (?, 'ready', 'world', '2026-01-02T00:00:00Z', '2026-01-02T00:00:00Z')`,
@@
     const draft2 = aiDrafts.findReady(bundle.conversation.id);
     assert.ok(draft2);
     aiDrafts.markDiscarded(draft2.id);
     assert.equal(aiDrafts.findReady(bundle.conversation.id), undefined);
+    const discardedRow = handle.db
+      .prepare("SELECT status FROM ai_drafts WHERE id = ?")
+      .get(draft2.id) as { status: string };
+    assert.equal(discardedRow.status, "discarded");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("marks draft as sent and discarded", async () => {
const conversations = new ConversationService(handle.db, 30);
const config = loadConfig({
TELEGRAM_BOT_TOKEN: "token",
TELEGRAM_MANAGEMENT_CHAT_ID: "-1001",
TELEGRAM_UPDATE_MODE: "polling",
TELEGRAM_ADMIN_USER_IDS: "1",
});
const aiDrafts = new AiDraftService(handle.db, conversations, config);
const bundle = await conversations.getOrCreateConversation({
platform: "telegram",
externalUserId: "123",
displayName: "Test",
});
handle.db
.prepare(
`INSERT INTO ai_drafts (conversation_id, status, draft_text, created_at, updated_at)
VALUES (?, 'ready', 'hello', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
)
.run(bundle.conversation.id);
const draft = aiDrafts.findReady(bundle.conversation.id);
assert.ok(draft);
aiDrafts.markSent(draft.id);
assert.equal(aiDrafts.findReady(bundle.conversation.id), undefined);
handle.db
.prepare(
`INSERT INTO ai_drafts (conversation_id, status, draft_text, created_at, updated_at)
VALUES (?, 'ready', 'world', '2026-01-02T00:00:00Z', '2026-01-02T00:00:00Z')`,
)
.run(bundle.conversation.id);
const draft2 = aiDrafts.findReady(bundle.conversation.id);
assert.ok(draft2);
aiDrafts.markDiscarded(draft2.id);
assert.equal(aiDrafts.findReady(bundle.conversation.id), undefined);
it("marks draft as sent and discarded", async () => {
const conversations = new ConversationService(handle.db, 30);
const config = loadConfig({
TELEGRAM_BOT_TOKEN: "token",
TELEGRAM_MANAGEMENT_CHAT_ID: "-1001",
TELEGRAM_UPDATE_MODE: "polling",
TELEGRAM_ADMIN_USER_IDS: "1",
});
const aiDrafts = new AiDraftService(handle.db, conversations, config);
const bundle = await conversations.getOrCreateConversation({
platform: "telegram",
externalUserId: "123",
displayName: "Test",
});
handle.db
.prepare(
`INSERT INTO ai_drafts (conversation_id, status, draft_text, created_at, updated_at)
VALUES (?, 'ready', 'hello', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
)
.run(bundle.conversation.id);
const draft = aiDrafts.findReady(bundle.conversation.id);
assert.ok(draft);
aiDrafts.markSent(draft.id);
assert.equal(aiDrafts.findReady(bundle.conversation.id), undefined);
const sentRow = handle.db
.prepare("SELECT status FROM ai_drafts WHERE id = ?")
.get(draft.id) as { status: string };
assert.equal(sentRow.status, "sent");
handle.db
.prepare(
`INSERT INTO ai_drafts (conversation_id, status, draft_text, created_at, updated_at)
VALUES (?, 'ready', 'world', '2026-01-02T00:00:00Z', '2026-01-02T00:00:00Z')`,
)
.run(bundle.conversation.id);
const draft2 = aiDrafts.findReady(bundle.conversation.id);
assert.ok(draft2);
aiDrafts.markDiscarded(draft2.id);
assert.equal(aiDrafts.findReady(bundle.conversation.id), undefined);
const discardedRow = handle.db
.prepare("SELECT status FROM ai_drafts WHERE id = ?")
.get(draft2.id) as { status: string };
assert.equal(discardedRow.status, "discarded");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core.test.ts` around lines 711 - 747, The test for AiDraftService
currently only checks that findReady() stops returning a draft, which would
still pass if markSent() and markDiscarded() accidentally set the same status.
Update this test to assert the persisted status values after calling
AiDraftService.markSent() and AiDraftService.markDiscarded(), using the draft
records created via the ai_drafts insert and the draft IDs returned by
findReady() so the new state semantics are explicitly covered.

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.

2 participants