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
33 changes: 2 additions & 31 deletions src/channels/telegram/bot.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,9 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Bot, webhookCallback } from "grammy";
import type { Logger } from "pino";
import { webhookCallback, type Bot } from "grammy";
import type { AppConfig } from "../../runtime/config.js";
import { AiDraftService } from "../../domain/ai-drafts.js";
import { AuditService } from "../../domain/audit.js";
import { ConversationService } from "../../domain/conversations.js";
import { DeliveryService } from "../../domain/deliveries.js";
import { PermissionService } from "../../domain/permissions.js";
import { RateLimitService } from "../../domain/rate-limit.js";
import type { Database } from "../../storage/client.js";
import { registerTelegramMenu } from "./menu.js";
import { registerTelegramUpdates } from "./updates.js";

export function createTelegramBot(config: AppConfig, db: Database, logger: Logger): Bot {
const bot = new Bot(config.TELEGRAM_BOT_TOKEN);
const conversations = new ConversationService(
db,
config.MESSAGE_RETENTION_DAYS,
config.DEFAULT_CONVERSATION_RETENTION_DAYS,
);
const deps = {
config,
conversations,
deliveries: new DeliveryService(db),
permissions: new PermissionService(config.TELEGRAM_ADMIN_USER_IDS),
rateLimit: new RateLimitService(config.RATE_LIMIT_WINDOW_SECONDS, config.RATE_LIMIT_MAX_MESSAGES),
aiDrafts: new AiDraftService(db, conversations, config),
audit: new AuditService(db),
logger: logger.child({ module: "telegram.messages" }),
};
registerTelegramUpdates(bot, deps);
return bot;
}
export { createTelegramBot } from "./factory.js";

export async function startTelegramBot(bot: Bot, config: AppConfig): Promise<void> {
await prepareTelegramBot(bot, config);
Expand Down
50 changes: 25 additions & 25 deletions src/channels/telegram/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export async function handleTopicCommand(
}
const updated = await deps.conversations.setConversationRetention(conversation.id, days);
if (updated) {
deps.audit.log({ adminId, conversationId: conversation.id, action: "expire", detail: days === null ? "never" : String(days) });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "expire", detail: days === null ? "never" : String(days) });
}
await ctx.reply(updated ? retentionText(updated) : "会话不存在,无法设置销毁策略。");
return true;
Expand Down Expand Up @@ -212,7 +212,7 @@ export async function handleTopicCommand(
return true;
}
await deps.conversations.addNote(conversation.id, adminId, args);
deps.audit.log({ adminId, conversationId: conversation.id, action: "note" });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "note" });
await ctx.reply("内部备注已保存。");
return true;
}
Expand All @@ -237,7 +237,7 @@ export async function handleTopicCommand(
return true;
}
await deps.conversations.addTag(conversation.id, args);
deps.audit.log({ adminId, conversationId: conversation.id, action: "tag", detail: args });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "tag", detail: args });
await ctx.reply(`标签已添加:${args}`);
return true;
}
Expand All @@ -247,7 +247,7 @@ export async function handleTopicCommand(
return true;
}
await deps.conversations.removeTag(conversation.id, args);
deps.audit.log({ adminId, conversationId: conversation.id, action: "untag", detail: args });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "untag", detail: args });
await ctx.reply(`标签已移除:${args}`);
return true;
}
Expand All @@ -262,7 +262,7 @@ export async function handleTopicCommand(
return true;
}
await deps.conversations.setPriority(conversation.id, args as "low" | "normal" | "high" | "urgent");
deps.audit.log({ adminId, conversationId: conversation.id, action: "priority", detail: args });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "priority", detail: args });
await ctx.reply(`优先级已设置为 ${args}。`);
return true;
}
Expand All @@ -272,19 +272,19 @@ export async function handleTopicCommand(
return true;
}
await deps.conversations.assign(conversation.id, args);
deps.audit.log({ adminId, conversationId: conversation.id, action: "assign", detail: args });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "assign", detail: args });
await ctx.reply(`已分配给 ${args}。`);
return true;
}
case "ban": {
await deps.conversations.blockContact(contact.id, adminId, args || undefined);
deps.audit.log({ adminId, conversationId: conversation.id, action: "ban", detail: args || undefined });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "ban", detail: args || undefined });
await ctx.reply("联系人已封禁,后续消息会被拒收。");
return true;
}
case "unban": {
await deps.conversations.unblockContact(contact.id);
deps.audit.log({ adminId, conversationId: conversation.id, action: "unban" });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "unban" });
await ctx.reply("联系人已解除封禁。");
return true;
}
Expand All @@ -294,7 +294,7 @@ export async function handleTopicCommand(
return true;
}
await ctx.api.deleteForumTopic(Number(topicContext.topic.managementChatId), topicContext.topic.messageThreadId);
deps.audit.log({ adminId, conversationId: conversation.id, action: "delete" });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "delete" });
await deps.conversations.deleteConversationData(conversation.id);
return true;
Comment on lines 296 to 299

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

先删 Topic 再删库会留下失效路由。

Line 296 成功而 Line 298 失败时,数据库仍保留当前会话与 Topic 映射,但 Telegram Topic 已经不存在。后续入站消息还会命中这条失效映射,直接造成 Topic 错路由或投递失败。这里至少要先移除可路由状态,再执行外部删除,或者把删除流程做成可补偿操作。 As per path instructions, “任何可能导致非白名单成员代发、bot 自己消息回环、Topic 错路由或用户数据泄露的问题都应标为高优先级。”

🤖 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 296 - 299, The delete flow in
the Telegram command handler leaves a stale conversation-to-Topic mapping if
`ctx.api.deleteForumTopic` succeeds but
`deps.conversations.deleteConversationData` fails, causing later routing to a
non-existent Topic. Update the deletion sequence in this handler so the routable
state is removed or marked inactive before the external Topic delete, or make
the operation compensating/transactional so a partial failure cannot leave an
invalid mapping behind. Use the `deleteForumTopic`, `deps.audit.log`, and
`deps.conversations.deleteConversationData` steps as the main places to adjust.

Source: Path instructions

}
Expand All @@ -304,20 +304,20 @@ export async function handleTopicCommand(
return true;
}
await deps.conversations.resetConversation(conversation.id);
deps.audit.log({ adminId, conversationId: conversation.id, action: "reset" });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "reset" });
await ctx.reply("会话已重置。联系人映射和 Topic 已保留。");
return true;
}
case "close": {
await deps.conversations.setConversationStatus(conversation.id, "closed");
deps.audit.log({ adminId, conversationId: conversation.id, action: "close" });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "close" });
await ctx.reply("会话已关闭。");
return true;
}
case "open":
case "reopen": {
await deps.conversations.setConversationStatus(conversation.id, "open");
deps.audit.log({ adminId, conversationId: conversation.id, action: "reopen" });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "reopen" });
await ctx.reply("会话已重新打开。");
return true;
}
Expand All @@ -328,14 +328,14 @@ export async function handleTopicCommand(
return true;
}
await deps.conversations.mute(conversation.id, mutedUntil);
deps.audit.log({ adminId, conversationId: conversation.id, action: "mute", detail: mutedUntil });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "mute", detail: mutedUntil });
await ctx.reply(`已静音至 ${mutedUntil}。`);
return true;
}
case "draft": {
const subCommand = args.trim().split(/\s+/)[0]?.toLowerCase();
if (subCommand === "send") {
const draft = deps.aiDrafts.findReady(conversation.id);
const draft = await deps.aiDrafts.findReady(conversation.id);
if (!draft || !draft.draftText) {
await ctx.reply("当前没有可发送的草稿,使用 /draft 生成。");
return true;
Expand All @@ -350,8 +350,8 @@ export async function handleTopicCommand(
await deps.deliveries.markFailed(deliveryId, errMsg, 3);
throw error;
}
deps.aiDrafts.markSent(draft.id);
deps.audit.log({ adminId, conversationId: conversation.id, action: "draft_send", detail: String(draft.id) });
await deps.aiDrafts.markSent(draft.id);
await deps.audit.log({ adminId, conversationId: conversation.id, action: "draft_send", detail: String(draft.id) });
await ctx.reply("草稿已发送给外部用户。");
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
Expand All @@ -360,18 +360,18 @@ export async function handleTopicCommand(
return true;
}
if (subCommand === "discard") {
const draft = deps.aiDrafts.findReady(conversation.id);
const draft = await deps.aiDrafts.findReady(conversation.id);
if (!draft) {
await ctx.reply("当前没有可丢弃的草稿。");
return true;
}
deps.aiDrafts.markDiscarded(draft.id);
deps.audit.log({ adminId, conversationId: conversation.id, action: "draft_discard", detail: String(draft.id) });
await deps.aiDrafts.markDiscarded(draft.id);
await deps.audit.log({ adminId, conversationId: conversation.id, action: "draft_discard", detail: String(draft.id) });
await ctx.reply("草稿已丢弃。");
return true;
}
if (subCommand === "view") {
const draft = deps.aiDrafts.findReady(conversation.id);
const draft = await deps.aiDrafts.findReady(conversation.id);
if (!draft || !draft.draftText) {
await ctx.reply("当前没有草稿,使用 /draft 生成。");
return true;
Expand Down Expand Up @@ -402,20 +402,20 @@ export async function handleTopicCommand(
}
case "ai_on": {
await deps.conversations.setAiEnabled(conversation.id, true);
deps.audit.log({ adminId, conversationId: conversation.id, action: "ai_on" });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "ai_on" });
const globalEnabled = isAiConfigured(deps.config);
const hint = globalEnabled ? "" : "\n\n提示:全局 AI 未开启,需先在控制台启用 AI_DRAFTS_ENABLED。";
await ctx.reply(`已对该会话开启 AI 草稿。${hint}`);
return true;
}
case "ai_off": {
await deps.conversations.setAiEnabled(conversation.id, false);
deps.audit.log({ adminId, conversationId: conversation.id, action: "ai_off" });
await deps.audit.log({ adminId, conversationId: conversation.id, action: "ai_off" });
await ctx.reply("已对该会话关闭 AI 草稿。该会话不再自动生成回复草稿。");
return true;
}
case "mine": {
const items = deps.conversations.listByAssignee(adminId, 20);
const items = await deps.conversations.listByAssignee(adminId, 20);
if (items.length === 0) {
await ctx.reply("当前没有分配给你的会话。使用 /assign <你的 user_id> 分配给自己。");
return true;
Expand All @@ -434,7 +434,7 @@ export async function handleTopicCommand(
await ctx.reply("用法:/search <关键词>,在当前会话历史消息中搜索。");
return true;
}
const results = deps.conversations.searchMessagesInConversation(conversation.id, args, 20);
const results = await deps.conversations.searchMessagesInConversation(conversation.id, args, 20);
if (results.length === 0) {
await ctx.reply("未找到匹配的消息。");
return true;
Expand All @@ -445,7 +445,7 @@ export async function handleTopicCommand(
}
case "audit": {
const limit = parseLimit(args, 20, 50);
const logs = deps.audit.listByConversation(conversation.id, limit);
const logs = await deps.audit.listByConversation(conversation.id, limit);
if (logs.length === 0) {
await ctx.reply("当前会话暂无审计记录。");
return true;
Expand Down
32 changes: 32 additions & 0 deletions src/channels/telegram/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Bot } from "grammy";
import type { Logger } from "pino";
import { AiDraftService } from "../../domain/ai-drafts.js";
import { AuditService } from "../../domain/audit.js";
import { ConversationService } from "../../domain/conversations.js";
import { DeliveryService } from "../../domain/deliveries.js";
import { PermissionService } from "../../domain/permissions.js";
import { RateLimitService } from "../../domain/rate-limit.js";
import type { Database } from "../../ports/database.js";
import type { AppConfig } from "../../runtime/config.js";
import { registerTelegramUpdates } from "./updates.js";

export function createTelegramBot(config: AppConfig, db: Database, logger: Logger): Bot {
const bot = new Bot(config.TELEGRAM_BOT_TOKEN);
const conversations = new ConversationService(
db,
config.MESSAGE_RETENTION_DAYS,
config.DEFAULT_CONVERSATION_RETENTION_DAYS,
);
const deps = {
config,
conversations,
deliveries: new DeliveryService(db),
permissions: new PermissionService(config.TELEGRAM_ADMIN_USER_IDS),
rateLimit: new RateLimitService(config.RATE_LIMIT_WINDOW_SECONDS, config.RATE_LIMIT_MAX_MESSAGES),
aiDrafts: new AiDraftService(db, conversations, config),
audit: new AuditService(db),
logger: logger.child({ module: "telegram.messages" }),
};
registerTelegramUpdates(bot, deps);
return bot;
}
8 changes: 8 additions & 0 deletions src/channels/telegram/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { AppConfig } from "../../runtime/config.js";

export async function telegramWebhookSecret(config: AppConfig): Promise<string> {
if (config.TELEGRAM_WEBHOOK_SECRET) return config.TELEGRAM_WEBHOOK_SECRET;
const data = new TextEncoder().encode(config.TELEGRAM_BOT_TOKEN);
const digest = await globalThis.crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
18 changes: 18 additions & 0 deletions src/channels/telegram/worker-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { webhookCallback, type Bot } from "grammy";

export type WorkerTelegramWebhookHandler = (request: Request) => Promise<Response>;
export type WorkerWebhookCallbackFactory = (bot: Bot, adapter: "cloudflare-mod") => WorkerTelegramWebhookHandler;

export function createWorkerTelegramWebhookHandler(
bot: Bot,
expectedSecret: string,
callbackFactory: WorkerWebhookCallbackFactory = webhookCallback as WorkerWebhookCallbackFactory,
): WorkerTelegramWebhookHandler {
const callback = callbackFactory(bot, "cloudflare-mod");
return async (request) => {
if (request.headers.get("x-telegram-bot-api-secret-token") !== expectedSecret) {
return new Response("Forbidden", { status: 403 });
}
return callback(request);
};
}
32 changes: 16 additions & 16 deletions src/domain/ai-drafts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AppConfig } from "../runtime/config.js";
import { isAiConfigured } from "../runtime/config.js";
import type { Database } from "../storage/client.js";
import type { Database } from "../ports/database.js";
import { nowIso, type ConversationService } from "./conversations.js";

export interface DraftResult {
Expand Down Expand Up @@ -72,13 +72,13 @@ export class AiDraftService {
}

const timestamp = nowIso();
this.db
await this.db
.prepare(
`INSERT INTO ai_drafts (conversation_id, source_message_id, status, created_at, updated_at)
VALUES (?, ?, 'pending', ?, ?)`,
)
.run(conversationId, sourceMessageId ?? null, timestamp, timestamp);
const row = this.db.prepare("SELECT last_insert_rowid() AS id").get() as { id: number };
const row = (await this.db.prepare("SELECT last_insert_rowid() AS id").get()) as { id: number };
const draftId = Number(row.id);
Comment on lines +75 to 82

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== file outline =='
ast-grep outline src/domain/ai-drafts.ts --view expanded || true

echo
echo '== relevant lines =='
wc -l src/domain/ai-drafts.ts
sed -n '1,240p' src/domain/ai-drafts.ts

Repository: one-ea/InboxBridge

Length of output: 8899


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect ai-drafts implementation and database adapter usage.
echo '== ai-drafts file outline =='
ast-grep outline src/domain/ai-drafts.ts --view expanded || true

echo
echo '== ai-drafts content =='
wc -l src/domain/ai-drafts.ts
sed -n '1,240p' src/domain/ai-drafts.ts

echo
echo '== search for last_insert_rowid / INSERT RETURNING / db adapter =='
rg -n "last_insert_rowid|INSERT INTO ai_drafts|returning|prepare\\(" src -g '*.ts'

Repository: one-ea/InboxBridge

Length of output: 18397


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the relevant file and surrounding code.
echo '== ai-drafts.ts =='
wc -l src/domain/ai-drafts.ts
sed -n '1,240p' src/domain/ai-drafts.ts

echo
echo '== usages of ai_drafts =='
rg -n "ai_drafts|last_insert_rowid|draftId" src -g '*.ts'

Repository: one-ea/InboxBridge

Length of output: 10222


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant module and database abstraction.
ast-grep outline src/domain/ai-drafts.ts --view expanded || true

echo
echo '== db wrapper references =='
rg -n "class .*Db|last_insert_rowid|better-sqlite3|d1|prepare\\(|run\\(|get\\(" src -g '*.ts'

echo
echo '== ai-drafts.ts excerpt =='
nl -ba src/domain/ai-drafts.ts | sed -n '1,220p'

Repository: one-ea/InboxBridge

Length of output: 18881


🌐 Web query:

SQLite last_insert_rowid connection scoped concurrent async same connection D1 meta.last_row_id documentation

💡 Result:

In SQLite, the last_insert_rowid function returns the rowid of the most recent successful INSERT operation performed on the same database connection [1][2]. This value is connection-scoped, meaning it is isolated from other database connections [3][4]. If multiple threads or concurrent tasks use the same database connection to perform inserts, the value returned by last_insert_rowid is not thread-safe [5][6]. If one thread performs an INSERT, and a second thread performs another INSERT on the same connection before the first thread calls last_insert_rowid, the value returned to the first thread will be the rowid of the second thread's insertion (or potentially unpredictable data if accessed during the operation) [1][3][2]. To safely obtain the rowid in concurrent environments, you must either ensure each thread/task uses its own dedicated connection or implement a mutex/locking mechanism to synchronize access to the connection [3][5][7]. For Cloudflare D1, which is built on SQLite, the equivalent metadata for identifying the last inserted row is available in the D1Result object's meta property as meta.last_row_id [8]. Cloudflare D1 databases are designed to be single-threaded at the database instance level and process queries one at a time, queueing concurrent requests if necessary [9]. Because D1 manages its internal SQLite connections, you should rely on the meta.last_row_id returned directly within the D1Result of your specific query to ensure you receive the rowid associated with that exact execution [8][10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect database abstraction to see whether inserted ids can be read from run().
sed -n '1,220p' src/ports/database.ts
echo
sed -n '1,220p' src/storage/client.ts
echo
sed -n '1,220p' src/storage/d1.ts
echo
sed -n '1,120p' src/domain/deliveries.ts

Repository: one-ea/InboxBridge

Length of output: 8217


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== ports/database.ts =='
sed -n '1,220p' src/ports/database.ts

echo
echo '== storage/client.ts =='
sed -n '1,220p' src/storage/client.ts

echo
echo '== storage/d1.ts =='
sed -n '1,220p' src/storage/d1.ts

echo
echo '== deliveries.ts excerpt =='
sed -n '1,80p' src/domain/deliveries.ts

Repository: one-ea/InboxBridge

Length of output: 7078


改用 run() 返回的 lastInsertRowid
这里先插入再单独 SELECT last_insert_rowid();在共享连接的并发请求下,draftId 可能读到别的插入结果,后面的 UPDATE ai_drafts ... WHERE id = ? 就会写到错误草稿。run() 已经在 Node SQLite / D1 适配器里统一返回 lastInsertRowid,直接接住并移除这次查询即可。

🤖 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 75 - 82, The draft insert flow in the
ai-drafts logic currently does an extra SELECT last_insert_rowid() after calling
prepare(...).run(), which can return the wrong id under shared-connection
concurrency. Update the insert path to use the lastInsertRowid returned directly
from the run() result in the ai-drafts method, and remove the follow-up SELECT
so the later UPDATE ai_drafts ... WHERE id = ? targets the correct draft.

Source: Path instructions


try {
Expand Down Expand Up @@ -136,7 +136,7 @@ export class AiDraftService {
}

const text = truncateText(rawText);
this.db
await this.db
.prepare("UPDATE ai_drafts SET status = 'ready', draft_text = ?, updated_at = ? WHERE id = ?")
.run(text, nowIso(), draftId);
const elapsed = Date.now() - startTime;
Expand All @@ -151,46 +151,46 @@ export class AiDraftService {
}

const message = lastError?.message ?? "AI draft generation failed.";
this.db
await this.db
.prepare("UPDATE ai_drafts SET status = 'failed', error = ?, updated_at = ? WHERE id = ?")
.run(message, nowIso(), draftId);
return { status: "failed", error: message };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.db
await this.db
.prepare("UPDATE ai_drafts SET status = 'failed', error = ?, updated_at = ? WHERE id = ?")
.run(message, nowIso(), draftId);
return { status: "failed", error: message };
}
}

findReady(conversationId: number): DraftRow | undefined {
const row = this.db
async findReady(conversationId: number): Promise<DraftRow | undefined> {
const row = (await 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;
.get(conversationId)) as Record<string, unknown> | undefined;
return row ? draftFromRow(row) : undefined;
}

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

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

stats(): { pending: number; ready: number; failed: number; sent: number; discarded: number } {
const rows = this.db
async stats(): Promise<{ pending: number; ready: number; failed: number; sent: number; discarded: number }> {
const rows = (await this.db
.prepare("SELECT status, COUNT(*) AS cnt FROM ai_drafts GROUP BY status")
.all() as Array<{ status: string; cnt: number }>;
.all()) as Array<{ status: string; cnt: number }>;
const result = { pending: 0, ready: 0, failed: 0, sent: 0, discarded: 0 };
for (const row of rows) {
if (row.status === "pending") result.pending = row.cnt;
Expand Down
Loading