-
Notifications
You must be signed in to change notification settings - Fork 0
feat(runtime): add Workers runtime foundation #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| } |
| 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(""); | ||
| } |
| 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); | ||
| }; | ||
| } |
| 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 { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.tsRepository: 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:
💡 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.tsRepository: 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.tsRepository: one-ea/InboxBridge Length of output: 7078 改用 🤖 Prompt for AI AgentsSource: Path instructions |
||
|
|
||
| try { | ||
|
|
@@ -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; | ||
|
|
@@ -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; | ||
|
|
||
There was a problem hiding this comment.
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
Source: Path instructions