// @ts-nocheck /** * ============================================================================ * 🤖 HG-TeleFlare — Cloudflare Telegram Bot Builder * ============================================================================ * A zero-config, self-bootstrapping Cloudflare Worker that dynamically * provisions its own D1, KV, and child Workers to build Telegram bots. * * @author Hamed Gharghi * @version 1.4.0 * @repository https://github.com/Hamed-Gharghi/Cloudflare-Telegram-bot-builder * @license MIT * ============================================================================ */ class Hono { constructor() { this.routes = []; } add(method, path, ...handlers) { this.routes.push({ method, path, handlers }); } get(path, ...handlers) { this.add('GET', path, ...handlers); } post(path, ...handlers) { this.add('POST', path, ...handlers); } put(path, ...handlers) { this.add('PUT', path, ...handlers); } patch(path, ...handlers) { this.add('PATCH', path, ...handlers); } delete(path, ...handlers) { this.add('DELETE', path, ...handlers); } async runHandlerChain(c, handlers, index) { if (index >= handlers.length) { return new Response('Not Found', { status: 404 }); } const handler = handlers[index]; if (handler.length >= 2) { const next = async () => this.runHandlerChain(c, handlers, index + 1); return handler(c, next); } return handler(c); } async fetch(request, env = {}, ctx = {}) { try { const url = new URL(request.url); const method = request.method.toUpperCase(); const pathname = url.pathname; for (const route of this.routes) { const params = matchRoute(route.path, pathname); if (!params || route.method !== method) continue; const c = new Context(request, env, ctx, params); const response = await this.runHandlerChain(c, route.handlers, 0); if (response instanceof Response) return response; return new Response(JSON.stringify({ error: 'Handler returned no response' }), { status: 500, headers: { 'Content-Type': 'application/json; charset=utf-8' }, }); } return new Response('Not Found', { status: 404 }); } catch (e) { console.error('Unhandled error:', e); return new Response(JSON.stringify({ error: e.message || 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json; charset=utf-8' }, }); } } } function matchRoute(routePath, pathname) { const routeParts = routePath.split('/').filter(Boolean); const pathParts = pathname.split('/').filter(Boolean); if (routeParts.length !== pathParts.length) return null; const params = {}; for (let i = 0; i < routeParts.length; i += 1) { const routePart = routeParts[i]; const pathPart = pathParts[i]; if (routePart.startsWith(':')) { params[routePart.slice(1)] = decodeURIComponent(pathPart); } else if (routePart !== pathPart) { return null; } } return params; } class Context { constructor(request, env, ctx, params) { this.request = request; this.req = { header: (name) => request.headers.get(name.toLowerCase()) || request.headers.get(name), json: async () => { const text = await request.text(); if (!text) return {}; return JSON.parse(text); }, formData: async () => request.formData(), param: (name) => this.params[name], url: request.url, }; this.env = env; this.ctx = ctx; this.params = params; this._store = new Map(); } set(key, value) { this._store.set(key, value); } get(key) { return this._store.get(key); } json(payload, status = 200) { return new Response(JSON.stringify(payload), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' }, }); } } function base64UrlEncode(value) { // Handle strings: encode to UTF-8 bytes first if (typeof value === 'string') { const bytes = new TextEncoder().encode(value); let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } // Handle binary data (Uint8Array, ArrayBuffer): encode bytes directly // IMPORTANT: Do NOT use TextDecoder/TextEncoder for binary data - it corrupts non-UTF-8 bytes const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); let binary = ''; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } function base64UrlDecode(value) { const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); const pad = '='.repeat((4 - (normalized.length % 4)) % 4); const binary = atob(normalized + pad); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } return bytes; } async function sign(payload, secret) { const header = { alg: 'HS256', typ: 'JWT' }; const headerSegment = base64UrlEncode(JSON.stringify(header)); const payloadSegment = base64UrlEncode(JSON.stringify(payload)); const signingInput = `${headerSegment}.${payloadSegment}`; const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)); return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`; } async function verify(token, secret) { const parts = token.split('.'); if (parts.length !== 3) throw new Error('Invalid token'); const [headerSegment, payloadSegment, signatureSegment] = parts; const signingInput = `${headerSegment}.${payloadSegment}`; const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)); const expectedSignature = base64UrlEncode(new Uint8Array(signature)); if (expectedSignature !== signatureSegment) { throw new Error('Invalid token'); } const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadSegment))); if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { throw new Error('Token expired'); } return payload; } const app = new Hono(); // ============================================================ // CLOUDFLARE API CLIENT — Handles all CF API v4 calls // ============================================================ const PLATFORM_RESOURCES = { d1: 'telegram-bot-builder-db', kv: 'telegram-bot-builder-session-kv', }; class CFClient { constructor(apiToken, accountId, d1Id, kvId) { this.apiToken = apiToken; this.accountId = accountId; this.d1Id = d1Id; this.kvId = kvId; this.baseUrl = 'https://api.cloudflare.com/client/v4'; this.headers = { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json', }; } // ── Generic API fetch ── async _fetch(path, options = {}) { const url = `${this.baseUrl}${path}`; const res = await fetch(url, { ...options, headers: { ...this.headers, ...options.headers }, }); const data = await res.json(); if (!data.success && data.errors?.[0]?.message) { throw new Error(data.errors[0].message); } if (!data.success) { throw new Error(`API error: ${JSON.stringify(data.errors || data)}`); } return data; } // ── Account Info ── async getAccountName() { const data = await this._fetch(`/accounts/${this.accountId}`); return data.result.name; } // ── D1 Operations ── async listD1Databases() { const data = await this._fetch(`/accounts/${this.accountId}/d1/database`); return data.result || []; } async createD1Database(name) { const data = await this._fetch(`/accounts/${this.accountId}/d1/database`, { method: 'POST', body: JSON.stringify({ name }), }); return data.result.uuid; } async ensureD1Database(name = PLATFORM_RESOURCES.d1) { const existing = (await this.listD1Databases()).find((db) => db.name === name); if (existing?.uuid) return { id: existing.uuid, created: false }; const id = await this.createD1Database(name); return { id, created: true }; } async d1Query(sql, params = []) { const body = { sql }; if (params.length > 0) body.params = params; const data = await this._fetch( `/accounts/${this.accountId}/d1/database/${this.d1Id}/query`, { method: 'POST', body: JSON.stringify(body) } ); return data.result?.[0]?.results || []; } async d1Execute(sql, params = []) { const body = { sql }; if (params.length > 0) body.params = params; const data = await this._fetch( `/accounts/${this.accountId}/d1/database/${this.d1Id}/query`, { method: 'POST', body: JSON.stringify(body) } ); return data.result?.[0] || {}; } // ── KV Operations ── async listKVNamespaces() { const data = await this._fetch(`/accounts/${this.accountId}/storage/kv/namespaces`); return data.result || []; } async createKVNamespace(name) { const data = await this._fetch(`/accounts/${this.accountId}/storage/kv/namespaces`, { method: 'POST', body: JSON.stringify({ title: name }), }); return data.result.id; } async ensureKVNamespace(name = PLATFORM_RESOURCES.kv) { const existing = (await this.listKVNamespaces()).find((ns) => ns.title === name); if (existing?.id) return { id: existing.id, created: false }; const id = await this.createKVNamespace(name); return { id, created: true }; } async kvGet(key) { const res = await fetch( `${this.baseUrl}/accounts/${this.accountId}/storage/kv/namespaces/${this.kvId}/values/${encodeURIComponent(key)}`, { headers: { 'Authorization': `Bearer ${this.apiToken}` } } ); if (res.status === 404) return null; return await res.text(); } async kvPut(key, value, expirationTtl = null) { // Cloudflare KV REST API: raw body = value, TTL as query param const qs = expirationTtl ? `?expiration_ttl=${encodeURIComponent(expirationTtl)}` : ''; const text = typeof value === 'string' ? value : JSON.stringify(value); const res = await fetch( `${this.baseUrl}/accounts/${this.accountId}/storage/kv/namespaces/${this.kvId}/values/${encodeURIComponent(key)}${qs}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${this.apiToken}`, 'Content-Type': 'text/plain; charset=utf-8', }, body: text, } ); if (!res.ok) { const errText = await res.text(); throw new Error(`KV put failed (${res.status}): ${errText.slice(0, 200)}`); } } async kvDelete(key) { await fetch( `${this.baseUrl}/accounts/${this.accountId}/storage/kv/namespaces/${this.kvId}/values/${encodeURIComponent(key)}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${this.apiToken}` }, } ); } async kvListKeys(prefix = '', limit = 1000) { if (!this.kvId) return []; const keys = []; let cursor = ''; do { const qs = new URLSearchParams({ limit: '1000' }); if (prefix) qs.set('prefix', prefix); if (cursor) qs.set('cursor', cursor); const data = await this._fetch( `/accounts/${this.accountId}/storage/kv/namespaces/${this.kvId}/keys?${qs.toString()}` ); const batch = data.result || []; for (const k of batch) { const name = typeof k === 'string' ? k : k?.name; if (name) keys.push(name); } cursor = data.result_info?.cursor || ''; if (!cursor || batch.length === 0) break; if (keys.length >= limit) break; } while (cursor); return keys.slice(0, limit); } async collectKvLogs(botId, { level = '', limit = 200 } = {}) { if (!this.kvId) return []; const logs = []; const seen = new Set(); const pushParsed = (parsed, id) => { if (!parsed || !parsed.message) return; if (level && String(parsed.level || '').toUpperCase() !== level.toUpperCase()) return; const dedupe = `${parsed.level}|${parsed.created_at}|${parsed.message}`; if (seen.has(dedupe)) return; seen.add(dedupe); logs.push({ id: id || dedupe, bot_id: botId, level: parsed.level || 'INFO', message: parsed.message, created_at: parsed.created_at || null, source: 'kv', }); }; // 1) Preferred: single aggregated list key (fast + not stuck on KV list lag) try { const rawList = await this.kvGet(`botlogs:${botId}`); if (rawList) { const list = JSON.parse(rawList); if (Array.isArray(list)) { for (let i = 0; i < list.length; i++) pushParsed(list[i], `botlogs:${botId}:${i}`); } } } catch { // fall through to per-key scan } // 2) Fallback / merge older per-entry keys (eventual-consistent list API) if (logs.length < limit) { try { const prefix = `botlog:${botId}:`; const keyNames = await this.kvListKeys(prefix, Math.min(Math.max(limit * 3, 100), 1000)); const orderedKeys = [...keyNames].sort().reverse().slice(0, Math.min(limit * 2, 200)); // Batch gets to stay within Worker subrequest limits const batchSize = 15; for (let i = 0; i < orderedKeys.length && logs.length < limit * 2; i += batchSize) { const batch = orderedKeys.slice(i, i + batchSize); const values = await Promise.all(batch.map((name) => this.kvGet(name).catch(() => null))); for (let j = 0; j < batch.length; j++) { try { if (!values[j]) continue; pushParsed(JSON.parse(values[j]), batch[j]); } catch { // skip bad entries } } } } catch { // ignore list failures when aggregated list already has data } } logs.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); return logs.slice(0, limit); } async collectKvUsers(botId) { if (!this.kvId) return []; const byId = new Map(); const remember = (parsed) => { if (!parsed) return; const tgId = String(parsed.telegram_user_id || parsed.id || '').trim(); if (!tgId || tgId === 'undefined' || tgId === 'null') return; const prev = byId.get(tgId) || {}; byId.set(tgId, { telegram_user_id: tgId, chat_id: String(parsed.chat_id || prev.chat_id || tgId), first_name: parsed.first_name || prev.first_name || null, last_name: parsed.last_name || prev.last_name || null, username: parsed.username || prev.username || null, last_seen_at: parsed.last_seen_at || parsed.updated_at || parsed.created_at || prev.last_seen_at || null, }); }; // 1) Preferred: aggregated subscriber list (one GET — no KV list lag) try { const rawUsers = await this.kvGet(`botusers:${botId}`); if (rawUsers) { const parsed = JSON.parse(rawUsers); if (Array.isArray(parsed)) { for (const u of parsed) remember(u); } else if (parsed && typeof parsed === 'object') { // Also support map shape { "123": { ... } } for (const u of Object.values(parsed)) remember(u); } } } catch { // fall through } // 2) Backfill from aggregated logs (same key Logs UI uses) try { const rawList = await this.kvGet(`botlogs:${botId}`); if (rawList) { const list = JSON.parse(rawList); if (Array.isArray(list)) { for (const entry of list) { if (entry?.user) { remember({ ...entry.user, telegram_user_id: entry.user.telegram_user_id || entry.user.id, chat_id: entry.chat_id || entry.user.chat_id || entry.user.id, last_seen_at: entry.created_at, }); } } } } } catch { // ignore } // 3) ALWAYS load per-user keys — different users never overwrite each other try { const userKeys = await this.kvListKeys(`botuser:${botId}:`, 2000); const batchSize = 20; for (let i = 0; i < userKeys.length; i += batchSize) { const batch = userKeys.slice(i, i + batchSize); const values = await Promise.all(batch.map((name) => this.kvGet(name).catch(() => null))); for (const raw of values) { if (!raw) continue; try { remember(JSON.parse(raw)); } catch { /* skip */ } } } } catch { // ignore } // 4) ALWAYS sample individual log keys for embedded user info try { const logKeys = await this.kvListKeys(`botlog:${botId}:`, 1000); const newest = [...logKeys].sort().reverse().slice(0, 200); const batchSize = 20; for (let i = 0; i < newest.length; i += batchSize) { const batch = newest.slice(i, i + batchSize); const values = await Promise.all(batch.map((name) => this.kvGet(name).catch(() => null))); for (const raw of values) { if (!raw) continue; try { const parsed = JSON.parse(raw); if (parsed?.user) { remember({ ...parsed.user, telegram_user_id: parsed.user.telegram_user_id || parsed.user.id, chat_id: parsed.chat_id || parsed.user.id, last_seen_at: parsed.created_at, }); } } catch { /* skip */ } } } } catch { // ignore } return Array.from(byId.values()); } // Maintain aggregated subscriber list in KV (readable with one GET) async upsertKvBotUser(botId, user = {}) { if (!this.kvId) return { ok: false, reason: 'no kv' }; const tgId = String(user.telegram_user_id || user.id || '').trim(); if (!tgId) return { ok: false, reason: 'missing id' }; const payload = { telegram_user_id: tgId, chat_id: String(user.chat_id || user.chatId || tgId), first_name: user.first_name || user.firstName || null, last_name: user.last_name || user.lastName || null, username: user.username || null, last_seen_at: user.last_seen_at || new Date().toISOString(), }; try { // Per-user key first — never races with other subscribers await this.kvPut(`botuser:${botId}:${tgId}`, JSON.stringify(payload), 86400 * 180); const listKey = `botusers:${botId}`; let map = {}; try { const prev = await this.kvGet(listKey); if (prev) { const parsed = JSON.parse(prev); if (Array.isArray(parsed)) { for (const u of parsed) { const id = String(u && u.telegram_user_id || ''); if (id) map[id] = u; } } else if (parsed && typeof parsed === 'object') { map = parsed; } } } catch { /* empty */ } map[tgId] = { ...(map[tgId] || {}), ...payload }; await this.kvPut(listKey, JSON.stringify(map), 86400 * 180); return { ok: true, telegram_user_id: tgId }; } catch (e) { console.error('upsertKvBotUser failed:', e.message); return { ok: false, reason: e.message }; } } // ── Media Storage (KV-backed — works automatically, no R2 needed!) ── // Store binary file data as base64 + content type in KV async kvPutMedia(key, data, contentType) { const encoded = JSON.stringify({ ct: contentType || 'application/octet-stream', d: base64UrlEncode(data), }); await this.kvPut(key, encoded); } async kvGetMedia(key) { const raw = await this.kvGet(key); if (!raw) return null; try { const parsed = JSON.parse(raw); if (parsed && parsed.d) { const bytes = base64UrlDecode(parsed.d); return { body: bytes, contentType: parsed.ct || 'application/octet-stream' }; } } catch { // Invalid stored data } return null; } // Persist platform config on this Worker so reloads skip setup async savePlatformConfig(scriptName, config) { if (!scriptName) return false; await this._fetch(`/accounts/${this.accountId}/workers/scripts/${encodeURIComponent(scriptName)}/secrets`, { method: 'PUT', body: JSON.stringify({ name: 'PLATFORM_CONFIG', text: JSON.stringify(config), type: 'secret_text', }), }); return true; } // ── Workers Operations (for deployment) ── async getWorkersSubdomain() { const data = await this._fetch(`/accounts/${this.accountId}/workers/subdomain`); return data.result?.subdomain || null; } async enableWorkerSubdomain(scriptName) { const path = `/accounts/${this.accountId}/workers/scripts/${encodeURIComponent(scriptName)}/subdomain`; let lastErr = null; // Cloudflare has used both POST and PUT for this endpoint over time. for (const method of ['POST', 'PUT']) { try { await this._fetch(path, { method, body: JSON.stringify({ enabled: true }), }); return true; } catch (e) { lastErr = e; } } throw new Error('Failed to enable workers.dev subdomain: ' + (lastErr?.message || 'unknown error')); } async deployWorker(scriptName, code, { envVars = {}, kvId = null, workersSubdomain = null } = {}) { // Worker names used on *.workers.dev must be DNS-safe (a-z, 0-9, - only) const safeName = String(scriptName) .toLowerCase() .replace(/[^a-z0-9-]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 63); if (!safeName) throw new Error('Invalid worker script name'); const bindings = [ ...Object.entries(envVars).map(([name, value]) => ({ type: 'plain_text', name, text: String(value), })), ]; if (kvId) { bindings.push({ type: 'kv_namespace', name: 'BOT_DATA', namespace_id: kvId, }); } const metadata = { main_module: 'worker.js', bindings, compatibility_date: '2024-12-01', }; const formData = new FormData(); formData.append('metadata', JSON.stringify(metadata)); formData.append( 'worker.js', new Blob([code], { type: 'application/javascript+module' }), 'worker.js' ); const res = await fetch( `${this.baseUrl}/accounts/${this.accountId}/workers/scripts/${encodeURIComponent(safeName)}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${this.apiToken}` }, body: formData, } ); const responseText = await res.text(); let data; try { data = JSON.parse(responseText); } catch (parseErr) { // Cloudflare may return non-JSON error pages (e.g. 1101 Worker errors, HTML error pages) const snippet = responseText.slice(0, 1000); throw new Error( `Cloudflare API returned HTTP ${res.status}: ${snippet}` ); } if (!data.success) { throw new Error(data.errors?.[0]?.message || 'Deploy failed'); } // Resolve subdomain early (parallel-safe promise) const subdomainPromise = workersSubdomain ? Promise.resolve(workersSubdomain) : this.getWorkersSubdomain().catch(() => null); // Ensure workers.dev route is enabled; do not swallow failures, because // webhook URLs rely on this and silent failure leads to 404 webhooks. await this.enableWorkerSubdomain(safeName); let subdomain = await subdomainPromise; if (!subdomain) { throw new Error('Could not resolve workers.dev subdomain. Enable it in the Cloudflare dashboard.'); } const workerUrl = `https://${safeName}.${subdomain}.workers.dev`; const botToken = envVars.BOT_TOKEN; // Keep deploy invocation lightweight to avoid Workers subrequest limits. // Readiness can be briefly eventual after deploy; webhook setup below is // the primary success criteria here. const readinessWarning = null; // Deploy webhook (critical — must succeed before returning) let webhook = null; if (botToken) { const hook = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: `${workerUrl}/webhook`, drop_pending_updates: true, allowed_updates: ['message', 'callback_query'], }), }); const hookData = await hook.json(); if (!hookData.ok) { throw new Error('Worker deployed but webhook failed: ' + (hookData.description || 'unknown error')); } webhook = { ok: true }; // Fire-and-forget webhook info (won't delay response) fetch(`https://api.telegram.org/bot${botToken}/getWebhookInfo`) .then(r => r.json()) .then(info => { if (info.last_error_message) { console.warn('Telegram webhook last error:', info.last_error_message); } }) .catch(() => {}); } return { ...data, scriptName: safeName, workerUrl, webhook, readinessWarning }; } // ── Ensure bot_logs table exists (for existing databases where migration already ran) ── async ensureBotLogsTable() { await this.ensureSchema(); } // ── Ensure bot_users table (broadcast / newsletter audience) ── async ensureBotUsersTable() { await this.ensureSchema(); } async ensureFormSubmissionsTable() { // Lightweight: do NOT run full ensureSchema (that alone can burn the free-plan 50 subrequest budget) if (CFClient._formSubmissionsReady) return; try { await this.d1Execute(`CREATE TABLE IF NOT EXISTS form_submissions ( id TEXT PRIMARY KEY, bot_id INTEGER NOT NULL, telegram_user_id TEXT, chat_id TEXT, first_name TEXT, last_name TEXT, username TEXT, data_json TEXT NOT NULL DEFAULT '{}', keys_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, FOREIGN KEY (bot_id) REFERENCES bots(id) )`); await this.d1Execute('CREATE INDEX IF NOT EXISTS idx_form_submissions_bot ON form_submissions(bot_id)'); await this.d1Execute('CREATE INDEX IF NOT EXISTS idx_form_submissions_created ON form_submissions(created_at)'); } catch { // Table/index may already exist } CFClient._formSubmissionsReady = true; } // ── Full schema ensure — safe to call anytime (setup, mid-dev, every API hit) ── async ensureSchema() { // Cache per Worker isolate — each d1Execute is an HTTP subrequest via the CF REST API if (CFClient._schemaReady) return; const statements = [ `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) )`, `CREATE TABLE IF NOT EXISTS bots ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, bot_name TEXT NOT NULL, bot_username TEXT, bot_token TEXT NOT NULL, worker_url TEXT, worker_script TEXT, is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) )`, `CREATE TABLE IF NOT EXISTS rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, bot_id INTEGER NOT NULL, trigger_type TEXT NOT NULL, trigger_value TEXT, match_type TEXT DEFAULT 'contains', action_type TEXT NOT NULL, action_params TEXT NOT NULL DEFAULT '{}', priority INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (bot_id) REFERENCES bots(id) )`, `CREATE TABLE IF NOT EXISTS media ( id INTEGER PRIMARY KEY AUTOINCREMENT, bot_id INTEGER NOT NULL, filename TEXT NOT NULL, r2_key TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (bot_id) REFERENCES bots(id) )`, `CREATE TABLE IF NOT EXISTS bot_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, bot_id INTEGER NOT NULL, level TEXT NOT NULL DEFAULT 'INFO', message TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (bot_id) REFERENCES bots(id) )`, `CREATE TABLE IF NOT EXISTS bot_users ( id INTEGER PRIMARY KEY AUTOINCREMENT, bot_id INTEGER NOT NULL, telegram_user_id TEXT NOT NULL, chat_id TEXT, first_name TEXT, last_name TEXT, username TEXT, last_seen_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')), UNIQUE(bot_id, telegram_user_id), FOREIGN KEY (bot_id) REFERENCES bots(id) )`, `CREATE TABLE IF NOT EXISTS form_submissions ( id TEXT PRIMARY KEY, bot_id INTEGER NOT NULL, telegram_user_id TEXT, chat_id TEXT, first_name TEXT, last_name TEXT, username TEXT, data_json TEXT NOT NULL DEFAULT '{}', keys_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, FOREIGN KEY (bot_id) REFERENCES bots(id) )`, 'CREATE INDEX IF NOT EXISTS idx_bots_user_id ON bots(user_id)', 'CREATE INDEX IF NOT EXISTS idx_rules_bot_id ON rules(bot_id)', 'CREATE INDEX IF NOT EXISTS idx_media_bot_id ON media(bot_id)', 'CREATE INDEX IF NOT EXISTS idx_bot_logs_bot_id ON bot_logs(bot_id)', 'CREATE INDEX IF NOT EXISTS idx_bot_logs_created ON bot_logs(created_at)', 'CREATE INDEX IF NOT EXISTS idx_bot_users_bot_id ON bot_users(bot_id)', 'CREATE INDEX IF NOT EXISTS idx_bot_users_telegram ON bot_users(telegram_user_id)', 'CREATE INDEX IF NOT EXISTS idx_form_submissions_bot ON form_submissions(bot_id)', 'CREATE INDEX IF NOT EXISTS idx_form_submissions_created ON form_submissions(created_at)', ]; for (const sql of statements) { try { await this.d1Execute(sql); } catch { // Ignore race / already-exists errors } } const alters = [ 'ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0', 'ALTER TABLE rules ADD COLUMN broken_media TEXT DEFAULT NULL', "ALTER TABLE rules ADD COLUMN match_type TEXT DEFAULT 'contains'", 'ALTER TABLE bot_users ADD COLUMN chat_id TEXT', 'ALTER TABLE bot_users ADD COLUMN last_name TEXT', 'ALTER TABLE bot_users ADD COLUMN username TEXT', 'ALTER TABLE bot_users ADD COLUMN last_seen_at TEXT', 'ALTER TABLE bot_logs ADD COLUMN created_at TEXT', ]; for (const sql of alters) { try { await this.d1Execute(sql); } catch { // Column may already exist } } // Ensure unique index for upserts (safe if table already existed without UNIQUE) try { await this.d1Execute( 'CREATE UNIQUE INDEX IF NOT EXISTS idx_bot_users_bot_telegram ON bot_users(bot_id, telegram_user_id)' ); } catch { // Index may already exist } CFClient._schemaReady = true; } async upsertBotUser(botId, user = {}) { await this.ensureBotUsersTable(); const tgId = String(user.telegram_user_id || user.id || '').trim(); if (!tgId) return { ok: false, reason: 'missing telegram_user_id' }; const chatId = String(user.chat_id || user.chatId || tgId); const firstName = user.first_name || user.firstName || null; const lastName = user.last_name || user.lastName || null; const username = user.username || null; const now = new Date().toISOString(); try { await this.d1Execute( `INSERT INTO bot_users (bot_id, telegram_user_id, chat_id, first_name, last_name, username, last_seen_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(bot_id, telegram_user_id) DO UPDATE SET chat_id = excluded.chat_id, first_name = excluded.first_name, last_name = excluded.last_name, username = excluded.username, last_seen_at = excluded.last_seen_at`, [botId, tgId, chatId, firstName, lastName, username, now, now] ); return { ok: true, telegram_user_id: tgId }; } catch (e) { // Fallback for DBs that don't support ON CONFLICT yet try { const existing = await this.d1Query( 'SELECT id FROM bot_users WHERE bot_id = ? AND telegram_user_id = ?', [botId, tgId] ); if (existing.length > 0) { await this.d1Execute( 'UPDATE bot_users SET chat_id = ?, first_name = ?, last_name = ?, username = ?, last_seen_at = ? WHERE id = ?', [chatId, firstName, lastName, username, now, existing[0].id] ); } else { await this.d1Execute( 'INSERT INTO bot_users (bot_id, telegram_user_id, chat_id, first_name, last_name, username, last_seen_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [botId, tgId, chatId, firstName, lastName, username, now, now] ); } return { ok: true, telegram_user_id: tgId, fallback: true }; } catch (e2) { console.error('upsertBotUser failed:', e.message, e2.message); return { ok: false, reason: e2.message || e.message }; } } } // ── Run initial database migrations ── async runMigrations() { await this.ensureSchema(); } } // ============================================================ // HELPERS // ============================================================ function html(strings, ...values) { return new Response( strings.reduce((acc, str, i) => acc + str + (values[i] || ''), ''), { headers: { 'Content-Type': 'text/html; charset=utf-8' } } ); } async function hashPassword(password) { const encoder = new TextEncoder(); const data = encoder.encode(password + 'telegram-bot-builder-salt'); const hash = await crypto.subtle.digest('SHA-256', data); return Array.from(new Uint8Array(hash)) .map(b => b.toString(16).padStart(2, '0')) .join(''); } // ============================================================ // CONFIG MIDDLEWARE — Reads setup from headers // ============================================================ function parsePlatformConfig(raw) { if (!raw) return null; try { const cfg = typeof raw === 'string' ? JSON.parse(raw) : raw; if (cfg?.accountId && cfg?.apiToken && cfg?.d1Id && cfg?.kvId) return cfg; } catch { // ignore invalid config } return null; } function getWorkerScriptName(requestOrUrl) { try { const url = typeof requestOrUrl === 'string' ? requestOrUrl : (requestOrUrl?.url || ''); const host = new URL(url).hostname; if (host.endsWith('.workers.dev')) { return host.split('.')[0]; } } catch { // ignore } return null; } function getCFClient(c) { const saved = parsePlatformConfig(c.env?.PLATFORM_CONFIG); const accountId = c.req.header('x-cf-account') || saved?.accountId; const apiToken = c.req.header('x-cf-token') || saved?.apiToken; const d1Id = c.req.header('x-cf-d1') || saved?.d1Id; const kvId = c.req.header('x-cf-kv') || saved?.kvId; if (!accountId || !apiToken || !d1Id || !kvId) return null; return new CFClient(apiToken, accountId, d1Id, kvId); } // Persist browser/header config onto the Worker secret so child bots can call parent APIs async function ensurePlatformConfigPersisted(c, cf) { if (!cf) return false; const saved = parsePlatformConfig(c.env?.PLATFORM_CONFIG); if (saved?.accountId && saved?.apiToken && saved?.d1Id && saved?.kvId) return true; const config = { accountId: cf.accountId, apiToken: cf.apiToken, d1Id: cf.d1Id, kvId: cf.kvId, }; const scriptName = c.req.header('x-cf-script') || getWorkerScriptName(c.req.url || c.request?.url || ''); if (!scriptName) return false; try { await cf.savePlatformConfig(scriptName, config); return true; } catch (e) { console.error('Failed to persist PLATFORM_CONFIG:', e.message); return false; } } function readUserCount(rows) { const row = rows?.[0] || {}; const raw = row.count ?? row.COUNT ?? row['COUNT(*)'] ?? 0; const n = Number(raw); return Number.isFinite(n) ? n : 0; } const VALID_MATCH_TYPES = ['contains', 'exact', 'starts_with', 'regex']; function normalizeMatchType(value, triggerType) { const raw = String(value || '').trim().toLowerCase(); if (VALID_MATCH_TYPES.includes(raw)) return raw; // Preserve legacy defaults: messages used contains; callbacks used exact equality if (triggerType === 'callback' || triggerType === 'command') return 'exact'; return 'contains'; } async function ensureRulesMatchTypeColumn(cf) { try { await cf.d1Execute("ALTER TABLE rules ADD COLUMN match_type TEXT DEFAULT 'contains'"); } catch { // Column may already exist } } // ============================================================ // SETUP ROUTES // ============================================================ // Check setup status app.get('/api/setup/status', async (c) => { const cf = getCFClient(c); const saved = parsePlatformConfig(c.env?.PLATFORM_CONFIG); if (cf) { try { await cf.ensureSchema(); } catch { /* mid-dev safe */ } } let hasUsers = false; if (cf) { try { const result = await cf.d1Query('SELECT COUNT(*) as count FROM users'); hasUsers = readUserCount(result) > 0; } catch { // Table may not exist yet — treat as no users } } return c.json({ configured: !!(cf || saved), hasD1: !!(cf?.d1Id || saved?.d1Id), hasKV: !!(cf?.kvId || saved?.kvId), hasUsers, config: saved ? { accountId: saved.accountId, d1Id: saved.d1Id, kvId: saved.kvId, // never return apiToken in status for safety when called without auth — // client already has it from PLATFORM_CONFIG injection on / } : null, }); }); // Initialize resources — streams live progress as NDJSON // Reuses existing D1/KV/R2 if already created (safe to re-run) app.post('/api/setup/init', async (c) => { const { accountId, apiToken, scriptName: clientScriptName } = await c.req.json(); if (!accountId || !apiToken) { return c.json({ error: 'Account ID and API Token are required' }, 400); } const scriptName = clientScriptName || getWorkerScriptName(c.req.url || c.request); const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); const encoder = new TextEncoder(); const send = (msg) => writer.write(encoder.encode(JSON.stringify(msg) + '\n')); // Run setup in background, stream progress (async () => { try { const cf = new CFClient(apiToken, accountId); let d1Id = '', kvId = ''; // Step 1: D1 — reuse if present await send({ step: 'd1', status: 'progress', message: 'Checking D1 database...' }); const d1 = await cf.ensureD1Database(PLATFORM_RESOURCES.d1); d1Id = d1.id; cf.d1Id = d1Id; await send({ step: 'd1', status: 'done', message: d1.created ? 'D1 database created' : 'Existing D1 database found', detail: d1Id.slice(0, 16) + '...', }); // Step 2: KV — reuse if present await send({ step: 'kv', status: 'progress', message: 'Checking KV namespace...' }); const kv = await cf.ensureKVNamespace(PLATFORM_RESOURCES.kv); kvId = kv.id; cf.kvId = kvId; await send({ step: 'kv', status: 'done', message: kv.created ? 'KV namespace created' : 'Existing KV namespace found', detail: kvId.slice(0, 16) + '...', }); // Step 3: Run migrations (IF NOT EXISTS — safe to re-run) await send({ step: 'migrations', status: 'progress', message: 'Ensuring database tables...' }); await cf.runMigrations(); await send({ step: 'migrations', status: 'done', message: 'Database tables ready (users, bots, rules, media)' }); const config = { accountId, apiToken, d1Id, kvId }; // Persist on the Worker secret so future reloads skip setup await send({ step: 'persist', status: 'progress', message: 'Saving platform config...' }); try { if (scriptName) { await cf.savePlatformConfig(scriptName, config); await send({ step: 'persist', status: 'done', message: 'Config saved on Worker (setup will be skipped next time)' }); } else { await send({ step: 'persist', status: 'done', message: 'Config saved in browser (could not detect Worker name)' }); } } catch (persistErr) { await send({ step: 'persist', status: 'done', message: 'Config saved in browser (Worker secret update skipped: ' + persistErr.message + ')', }); } // Complete await send({ step: 'complete', status: 'done', message: 'Platform is ready!', config: { accountId, d1Id, kvId }, }); } catch (e) { await send({ step: 'error', status: 'error', message: e.message }); } finally { await writer.close(); } })(); return new Response(readable, { headers: { 'Content-Type': 'application/x-ndjson' }, }); }); // Verify token has correct permissions app.post('/api/setup/verify-token', async (c) => { const { apiToken } = await c.req.json(); if (!apiToken) return c.json({ error: 'API Token required' }, 400); try { // Test the token by getting the user's accounts const res = await fetch('https://api.cloudflare.com/client/v4/accounts', { headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' }, }); const data = await res.json(); if (!data.success) { return c.json({ error: data.errors?.[0]?.message || 'Invalid API token' }, 400); } const accounts = data.result || []; return c.json({ success: true, accounts: accounts.map((a) => ({ id: String(a.id ?? ''), name: String(a.name ?? 'Unnamed Account'), })).filter((a) => a.id), }); } catch (e) { return c.json({ error: e.message }, 500); } }); // ============================================================ // AUTH ROUTES (using CFClient for D1) // ============================================================ app.post('/api/register', async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Platform not initialized. Complete setup first.' }, 400); // Only the first user can register (the admin) const userCount = await cf.d1Query('SELECT COUNT(*) as count FROM users'); if (readUserCount(userCount) > 0) { return c.json({ error: 'Registration is closed. The admin account already exists. Please sign in instead.', code: 'REGISTRATION_CLOSED', }, 403); } const { username, password } = await c.req.json(); if (!username || !password || password.length < 6) { return c.json({ error: 'Username and password (min 6 chars) required' }, 400); } const existing = await cf.d1Query('SELECT * FROM users WHERE username = ?', [username]); if (existing.length > 0) { return c.json({ error: 'Username already exists' }, 409); } const passwordHash = await hashPassword(password); // Insert as admin (first and only user) await cf.d1Execute('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)', [username, passwordHash]); const newUsers = await cf.d1Query('SELECT id, username, created_at FROM users WHERE username = ?', [username]); const user = newUsers[0]; if (!user) { return c.json({ error: 'Registration failed — user was not created. Please try again.' }, 500); } const secret = c.env?.JWT_SECRET || 'telegram-bot-builder-secret'; const token = await sign({ sub: user.id, username, exp: Math.floor(Date.now() / 1000) + 86400 * 7, }, secret); return c.json({ token, user: { id: user.id, username, created_at: user.created_at } }); }); app.post('/api/login', async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Platform not initialized. Complete setup first.' }, 400); const { username, password } = await c.req.json(); const result = await cf.d1Query('SELECT * FROM users WHERE username = ?', [username]); if (result.length === 0) { return c.json({ error: 'Invalid credentials' }, 401); } const passwordHash = await hashPassword(password); if (result[0].password_hash !== passwordHash) { return c.json({ error: 'Invalid credentials' }, 401); } const secret = c.env?.JWT_SECRET || 'telegram-bot-builder-secret'; const token = await sign({ sub: result[0].id, username, exp: Math.floor(Date.now() / 1000) + 86400 * 7, }, secret); return c.json({ token, user: { id: result[0].id, username } }); }); // ============================================================ // AUTH MIDDLEWARE // ============================================================ const authMiddleware = async (c, next) => { let authenticated = false; // First: try JWT-based auth const authHeader = c.req.header('Authorization'); if (authHeader && authHeader.startsWith('Bearer ')) { try { const secret = c.env?.JWT_SECRET || 'telegram-bot-builder-secret'; const token = authHeader.slice(7); const payload = await verify(token, secret); c.set('userId', payload.sub); c.set('username', payload.username); authenticated = true; } catch (e) { console.error('auth: jwt verify failed -', e.message); // JWT failed — fall through to CF-based fallback } } // Second: try Cloudflare API token auth (fallback) // This is secure because only the admin knows the CF API token if (!authenticated) { try { const cf = getCFClient(c); if (cf) { const users = await cf.d1Query('SELECT id, username FROM users WHERE is_admin = 1 LIMIT 1'); if (users.length > 0) { console.log('auth: using CF API fallback for user', users[0].id); c.set('userId', users[0].id); c.set('username', users[0].username); authenticated = true; } } } catch (e) { console.error('auth: CF fallback failed:', e.message); } } if (!authenticated) { return c.json({ error: 'Unauthorized' }, 401); } // Lazy-migrate schema on every authenticated request (safe mid-development) try { const cf = getCFClient(c); if (cf) { await cf.ensureSchema(); // Keep PLATFORM_CONFIG secret in sync so child bot → parent logging works c.ctx?.waitUntil?.(ensurePlatformConfigPersisted(c, cf)); if (!c.ctx?.waitUntil) { await ensurePlatformConfigPersisted(c, cf); } } } catch (e) { console.error('schema ensure failed:', e.message); } // CRITICAL: Execute the route OUTSIDE the authentication try/catch blocks! return await next(); }; // ============================================================ // AUTH CHECK ENDPOINT (debug helper) // ============================================================ app.get('/api/auth/check', authMiddleware, async (c) => { const userId = c.get('userId'); const username = c.get('username'); return c.json({ valid: true, userId, username }); }); // ============================================================ // TELEGRAM API HELPER // ============================================================ async function callTelegramAPI(token, method, params = {}) { const url = `https://api.telegram.org/bot${token}/${method}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), }); return await response.json(); } // ============================================================ // BOT CRUD ROUTES // ============================================================ app.get('/api/bots', authMiddleware, async (c) => { try { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const results = await cf.d1Query('SELECT * FROM bots WHERE user_id = ? ORDER BY created_at DESC', [userId]); return c.json({ bots: results }); } catch (e) { return c.json({ error: e.message || 'Failed to load bots' }, 500); } }); app.post('/api/bots', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const { botToken } = await c.req.json(); if (!botToken) return c.json({ error: 'Bot token is required' }, 400); const me = await callTelegramAPI(botToken, 'getMe'); if (!me.ok) { return c.json({ error: 'Invalid bot token. Get it from @BotFather on Telegram.' }, 400); } const botName = me.result.first_name; const botUsername = me.result.username; // Insert and then SELECT (RETURNING may not work via D1 REST API) await cf.d1Execute( 'INSERT INTO bots (user_id, bot_name, bot_username, bot_token) VALUES (?, ?, ?, ?)', [userId, botName, botUsername, botToken] ); const bots = await cf.d1Query('SELECT * FROM bots WHERE bot_token = ?', [botToken]); return c.json({ bot: bots[0] }); }); // Update bot (change bot token) app.put('/api/bots/:id', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const { botToken } = await c.req.json(); if (!botToken) return c.json({ error: 'Bot token is required' }, 400); // Verify ownership const bots = await cf.d1Query('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); // Validate new token with Telegram const me = await callTelegramAPI(botToken, 'getMe'); if (!me.ok) { return c.json({ error: 'Invalid bot token. Get it from @BotFather on Telegram.' }, 400); } const botName = me.result.first_name; const botUsername = me.result.username; // Update bot record await cf.d1Execute( 'UPDATE bots SET bot_token = ?, bot_name = ?, bot_username = ? WHERE id = ?', [botToken, botName, botUsername, botId] ); const updatedBots = await cf.d1Query('SELECT * FROM bots WHERE id = ?', [botId]); return c.json({ success: true, bot: updatedBots[0], message: `✅ Bot updated! @${botUsername} is now connected.`, }); }); app.get('/api/bots/:id', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const bots = await cf.d1Query('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); const rules = await cf.d1Query('SELECT * FROM rules WHERE bot_id = ? ORDER BY priority ASC', [botId]); return c.json({ bot: bots[0], rules }); }); app.delete('/api/bots/:id', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const bots = await cf.d1Query('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); await cf.d1Execute('DELETE FROM rules WHERE bot_id = ?', [botId]); await cf.d1Execute('DELETE FROM bots WHERE id = ?', [botId]); return c.json({ success: true }); }); // ============================================================ // RULES CRUD ROUTES // ============================================================ app.post('/api/bots/:id/rules', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const { triggerType, triggerValue, matchType, actionType, actionParams, priority } = await c.req.json(); const bots = await cf.d1Query('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); await ensureRulesMatchTypeColumn(cf); const normalizedMatch = normalizeMatchType(matchType, triggerType); // Insert and then SELECT (RETURNING may not work via D1 REST API) await cf.d1Execute( 'INSERT INTO rules (bot_id, trigger_type, trigger_value, match_type, action_type, action_params, priority) VALUES (?, ?, ?, ?, ?, ?, ?)', [botId, triggerType, triggerValue || null, normalizedMatch, actionType, JSON.stringify(actionParams || {}), priority || 0] ); const rules = await cf.d1Query('SELECT * FROM rules WHERE bot_id = ? ORDER BY created_at DESC LIMIT 1', [botId]); return c.json({ rule: rules[0] }); }); app.put('/api/rules/:id', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const ruleId = parseInt(c.req.param('id')); const { triggerType, triggerValue, matchType, actionType, actionParams, priority } = await c.req.json(); const rules = await cf.d1Query( `SELECT r.*, b.user_id FROM rules r JOIN bots b ON r.bot_id = b.id WHERE r.id = ?`, [ruleId] ); if (rules.length === 0) return c.json({ error: 'Rule not found' }, 404); if (rules[0].user_id !== userId) return c.json({ error: 'Unauthorized' }, 403); await ensureRulesMatchTypeColumn(cf); const updates = []; const params = []; if (triggerType !== undefined) { updates.push('trigger_type = ?'); params.push(triggerType); } if (triggerValue !== undefined) { updates.push('trigger_value = ?'); params.push(triggerValue); } if (matchType !== undefined || triggerType !== undefined) { const tt = triggerType !== undefined ? triggerType : rules[0].trigger_type; updates.push('match_type = ?'); params.push(normalizeMatchType(matchType !== undefined ? matchType : rules[0].match_type, tt)); } if (actionType !== undefined) { updates.push('action_type = ?'); params.push(actionType); } if (actionParams !== undefined) { updates.push('action_params = ?'); params.push(JSON.stringify(actionParams)); updates.push('broken_media = ?'); params.push(null); } if (priority !== undefined) { updates.push('priority = ?'); params.push(priority); } if (updates.length > 0) { // Ensure broken_media column exists (safe no-op if already exists) try { await cf.d1Execute("ALTER TABLE rules ADD COLUMN broken_media TEXT DEFAULT NULL"); } catch { // Column may already exist — safe to ignore } params.push(ruleId); await cf.d1Execute(`UPDATE rules SET ${updates.join(', ')} WHERE id = ?`, params); } return c.json({ success: true }); }); app.delete('/api/rules/:id', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const ruleId = parseInt(c.req.param('id')); const rules = await cf.d1Query( `SELECT r.*, b.user_id FROM rules r JOIN bots b ON r.bot_id = b.id WHERE r.id = ?`, [ruleId] ); if (rules.length === 0) return c.json({ error: 'Rule not found' }, 404); if (rules[0].user_id !== userId) return c.json({ error: 'Unauthorized' }, 403); await cf.d1Execute('DELETE FROM rules WHERE id = ?', [ruleId]); return c.json({ success: true }); }); // Duplicate a rule app.post('/api/rules/:id/duplicate', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const ruleId = parseInt(c.req.param('id')); const rules = await cf.d1Query( `SELECT r.*, b.user_id FROM rules r JOIN bots b ON r.bot_id = b.id WHERE r.id = ?`, [ruleId] ); if (rules.length === 0) return c.json({ error: 'Rule not found' }, 404); if (rules[0].user_id !== userId) return c.json({ error: 'Unauthorized' }, 403); const original = rules[0]; await ensureRulesMatchTypeColumn(cf); await cf.d1Execute( 'INSERT INTO rules (bot_id, trigger_type, trigger_value, match_type, action_type, action_params, priority) VALUES (?, ?, ?, ?, ?, ?, ?)', [ original.bot_id, original.trigger_type, original.trigger_value, normalizeMatchType(original.match_type, original.trigger_type), original.action_type, original.action_params, (original.priority || 0) + 1, ] ); return c.json({ success: true, message: '✅ Rule duplicated!' }); }); // Export bot rules as a shareable JSON template app.get('/api/bots/:id/export', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const bots = await cf.d1Query('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); await ensureRulesMatchTypeColumn(cf); const rules = await cf.d1Query('SELECT * FROM rules WHERE bot_id = ? ORDER BY priority ASC, id ASC', [botId]); const template = { format: 'hg-teleflare-template', version: 1, name: bots[0].bot_name || bots[0].bot_username || `bot-${botId}`, exportedAt: new Date().toISOString(), rules: rules.map((r) => ({ trigger_type: r.trigger_type, trigger_value: r.trigger_value, match_type: normalizeMatchType(r.match_type, r.trigger_type), action_type: r.action_type, action_params: typeof r.action_params === 'string' ? (() => { try { return JSON.parse(r.action_params || '{}'); } catch { return {}; } })() : (r.action_params || {}), priority: Number(r.priority) || 0, })), }; return c.json(template); }); // Import rules from a JSON template app.post('/api/bots/:id/import', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const body = await c.req.json(); const mode = body.mode === 'replace' ? 'replace' : 'append'; const bots = await cf.d1Query('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); let incoming = body.rules; if (!incoming && body.template?.rules) incoming = body.template.rules; if (!Array.isArray(incoming) || incoming.length === 0) { return c.json({ error: 'No rules found in template. Expected { rules: [...] }' }, 400); } if (incoming.length > 200) { return c.json({ error: 'Template too large (max 200 rules)' }, 400); } const allowedTriggers = new Set(['command', 'message', 'callback', 'new_member', 'state']); const allowedActions = new Set([ 'send_message', 'send_photo', 'send_document', 'show_keyboard', 'store_data', 'fetch_api', 'forward', 'set_state', 'clear_state', 'send_form_summary', ]); const normalized = []; for (let i = 0; i < incoming.length; i += 1) { const r = incoming[i] || {}; const triggerType = String(r.trigger_type || r.triggerType || '').trim(); const actionType = String(r.action_type || r.actionType || '').trim(); if (!allowedTriggers.has(triggerType)) { return c.json({ error: `Rule #${i + 1}: invalid trigger_type "${triggerType}"` }, 400); } if (!allowedActions.has(actionType)) { return c.json({ error: `Rule #${i + 1}: invalid action_type "${actionType}"` }, 400); } let actionParams = r.action_params ?? r.actionParams ?? {}; if (typeof actionParams === 'string') { try { actionParams = JSON.parse(actionParams || '{}'); } catch { return c.json({ error: `Rule #${i + 1}: action_params must be valid JSON` }, 400); } } if (!actionParams || typeof actionParams !== 'object' || Array.isArray(actionParams)) { return c.json({ error: `Rule #${i + 1}: action_params must be an object` }, 400); } normalized.push({ triggerType, triggerValue: r.trigger_value ?? r.triggerValue ?? null, matchType: normalizeMatchType(r.match_type ?? r.matchType, triggerType), actionType, actionParams, priority: Number(r.priority) || i, }); } await ensureRulesMatchTypeColumn(cf); if (mode === 'replace') { await cf.d1Execute('DELETE FROM rules WHERE bot_id = ?', [botId]); } for (const rule of normalized) { await cf.d1Execute( 'INSERT INTO rules (bot_id, trigger_type, trigger_value, match_type, action_type, action_params, priority) VALUES (?, ?, ?, ?, ?, ?, ?)', [ botId, rule.triggerType, rule.triggerValue, rule.matchType, rule.actionType, JSON.stringify(rule.actionParams), rule.priority, ] ); } const rules = await cf.d1Query('SELECT * FROM rules WHERE bot_id = ? ORDER BY priority ASC, id ASC', [botId]); return c.json({ success: true, imported: normalized.length, mode, rules, message: `✅ Imported ${normalized.length} rule(s) (${mode})`, }); }); // Bulk reorder rules (drag-and-drop) app.post('/api/bots/:id/rules/reorder', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const { ruleIds } = await c.req.json(); if (!Array.isArray(ruleIds) || ruleIds.length === 0) { return c.json({ error: 'ruleIds array is required' }, 400); } // Verify bot ownership const bots = await cf.d1Query('SELECT id FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); // Update priorities based on position in the array for (let i = 0; i < ruleIds.length; i++) { await cf.d1Execute('UPDATE rules SET priority = ? WHERE id = ? AND bot_id = ?', [i * 10, ruleIds[i], botId]); } const updatedRules = await cf.d1Query( 'SELECT * FROM rules WHERE bot_id = ? ORDER BY priority ASC, id ASC', [botId] ); return c.json({ success: true, rules: updatedRules }); }); // ============================================================ // MEDIA UPLOAD ROUTE // ============================================================ app.post('/api/media/upload', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); try { const userId = c.get('userId'); const formData = await c.req.formData(); const file = formData.get('file'); const botId = parseInt(formData.get('bot_id') || '0'); if (!file) return c.json({ error: 'No file provided' }, 400); // ── Enforce 15MB size limit (KV supports 25MB max, Base64 adds ~33%, so 15MB keeps us safe) ── const MAX_FILE_SIZE = 15 * 1024 * 1024; // 15MB if (file.size > MAX_FILE_SIZE) { const sizeMB = (file.size / (1024 * 1024)).toFixed(1); return c.json({ error: `File too large (${sizeMB}MB). Maximum is 15MB.` }, 413); } // Verify bot ownership const bots = await cf.d1Query('SELECT id FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); const timestamp = Date.now(); const kvKey = `media_${botId}_${timestamp}`; const buffer = await file.arrayBuffer(); // Store file in KV (auto-provisioned during setup — no manual steps needed!) await cf.kvPutMedia(kvKey, buffer, file.type); await cf.d1Execute( 'INSERT INTO media (bot_id, filename, r2_key) VALUES (?, ?, ?)', [botId, file.name, kvKey] ); const mediaResult = await cf.d1Query('SELECT * FROM media WHERE r2_key = ?', [kvKey]); // Generate a URL for the uploaded file const host = new URL(c.req.url || c.request.url); const fileUrl = host.origin + '/api/media/' + mediaResult[0].id + '/file'; return c.json({ media: { ...mediaResult[0], url: fileUrl } }); } catch (e) { return c.json({ error: 'Upload failed: ' + (e.message || 'Unknown error') }, 500); } }); // ── Media List ── app.get('/api/bots/:id/media', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const bots = await cf.d1Query('SELECT id FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); const media = await cf.d1Query( 'SELECT * FROM media WHERE bot_id = ? ORDER BY created_at DESC', [botId] ); // Attach URLs const host = new URL(c.req.url || c.request.url); const mediaWithUrls = (media || []).map(m => ({ ...m, url: host.origin + '/api/media/' + m.id + '/file', })); return c.json({ media: mediaWithUrls }); }); // ── Media Delete ── app.delete('/api/media/:id', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const mediaId = parseInt(c.req.param('id')); // Get media record and verify ownership via bot const mediaRows = await cf.d1Query( `SELECT m.*, b.user_id FROM media m JOIN bots b ON m.bot_id = b.id WHERE m.id = ?`, [mediaId] ); if (mediaRows.length === 0) return c.json({ error: 'Media not found' }, 404); if (mediaRows[0].user_id !== userId) return c.json({ error: 'Unauthorized' }, 403); // Delete from KV storage try { await cf.kvDelete(mediaRows[0].r2_key); } catch { // KV delete may fail if key doesn't exist — proceed with DB delete } const botId = mediaRows[0].bot_id; const mediaFilename = mediaRows[0].filename; // Delete from DB await cf.d1Execute('DELETE FROM media WHERE id = ?', [mediaId]); // ── Find rules that reference this deleted media and mark them as broken ── const mediaUrlPattern = `%/api/media/${mediaId}/file%`; const affectedRules = await cf.d1Query( 'SELECT id, action_params FROM rules WHERE bot_id = ? AND action_params LIKE ?', [botId, mediaUrlPattern] ); const affectedRuleIds = []; // Ensure broken_media column exists (safe no-op if already present) try { await cf.d1Execute("ALTER TABLE rules ADD COLUMN broken_media TEXT DEFAULT NULL"); } catch { // Column may already exist } for (const rule of affectedRules) { affectedRuleIds.push(rule.id); // Build broken_media info: merge with any existing broken media references const existingBroken = rule.broken_media ? (() => { try { return JSON.parse(rule.broken_media); } catch { return []; } })() : []; const alreadyTracked = existingBroken.some(b => b.id === mediaId); if (!alreadyTracked) { existingBroken.push({ id: mediaId, filename: mediaFilename }); } await cf.d1Execute('UPDATE rules SET broken_media = ? WHERE id = ?', [JSON.stringify(existingBroken), rule.id]); } return c.json({ success: true, message: '✅ Media deleted!' + (affectedRuleIds.length > 0 ? ' ⚠️ ' + affectedRuleIds.length + ' rule(s) affected.' : ''), affectedRules: affectedRuleIds, mediaFilename, }); }); // ── Media Rename ── app.put('/api/media/:id', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const mediaId = parseInt(c.req.param('id')); const { filename } = await c.req.json(); if (!filename || !filename.trim()) { return c.json({ error: 'Filename is required' }, 400); } const mediaRows = await cf.d1Query( `SELECT m.*, b.user_id FROM media m JOIN bots b ON m.bot_id = b.id WHERE m.id = ?`, [mediaId] ); if (mediaRows.length === 0) return c.json({ error: 'Media not found' }, 404); if (mediaRows[0].user_id !== userId) return c.json({ error: 'Unauthorized' }, 403); await cf.d1Execute('UPDATE media SET filename = ? WHERE id = ?', [filename.trim(), mediaId]); const updated = await cf.d1Query('SELECT * FROM media WHERE id = ?', [mediaId]); const host = new URL(c.req.url || c.request.url); return c.json({ success: true, media: { ...updated[0], url: host.origin + '/api/media/' + mediaId + '/file' }, }); }); // ── Media File Proxy ── app.get('/api/media/:id/file', async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const mediaId = parseInt(c.req.param('id')); const mediaRows = await cf.d1Query('SELECT * FROM media WHERE id = ?', [mediaId]); if (mediaRows.length === 0) return new Response('Not Found', { status: 404 }); const media = mediaRows[0]; const kvData = await cf.kvGetMedia(media.r2_key); if (!kvData) return new Response('File not found in storage', { status: 404 }); // Return the file with stored content type return new Response(kvData.body, { headers: { 'Content-Type': kvData.contentType, 'Content-Disposition': 'inline; filename="' + media.filename + '"', 'Cache-Control': 'public, max-age=31536000', }, }); }); // ============================================================ // LOG API ROUTES // ============================================================ // Receive logs from deployed bot workers (called by the bot itself via HTTP) app.post('/api/logs/:id', async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); // Ensure tables exist (safe mid-development) await cf.ensureSchema(); const botId = parseInt(c.req.param('id')); let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON body' }, 400); } const { token, level, message: logMessage, user, chat_id } = body; if (!level || !logMessage) { return c.json({ error: 'Level and message required' }, 400); } // Verify bot token so only the actual bot can log const bots = await cf.d1Query('SELECT bot_token FROM bots WHERE id = ?', [botId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); if (bots[0].bot_token !== token) return c.json({ error: 'Unauthorized' }, 403); await cf.d1Execute( 'INSERT INTO bot_logs (bot_id, level, message, created_at) VALUES (?, ?, ?, ?)', [botId, String(level).toUpperCase(), logMessage, new Date().toISOString()] ); // Piggyback subscriber tracking on every log that includes user info // (broadcast audience) — works even if the separate /users/track call fails let tracked = null; if (user && (user.id || user.telegram_user_id)) { const userPayload = { telegram_user_id: user.id || user.telegram_user_id, chat_id: chat_id || user.chat_id || user.id || user.telegram_user_id, first_name: user.first_name, last_name: user.last_name, username: user.username, }; tracked = await cf.upsertBotUser(botId, userPayload); // Also keep KV audience list in sync (same store Logs already use) try { await cf.upsertKvBotUser(botId, userPayload); } catch (_) {} } return c.json({ success: true, tracked: tracked?.ok || false }); }); // Fetch logs for a bot (with optional level filter and limit) app.get('/api/bots/:id/logs', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); await cf.ensureBotLogsTable(); await ensurePlatformConfigPersisted(c, cf); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const url = new URL(c.req.url, 'http://localhost'); const level = url.searchParams.get('level') || ''; const limit = Math.min(parseInt(url.searchParams.get('limit') || '200', 10) || 200, 500); const bots = await cf.d1Query('SELECT id FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); let sql = 'SELECT * FROM bot_logs WHERE bot_id = ?'; const params = [botId]; if (level && ['DEBUG', 'INFO', 'WARNING', 'ERROR'].includes(level.toUpperCase())) { sql += ' AND level = ?'; params.push(level.toUpperCase()); } sql += ' ORDER BY created_at DESC LIMIT ?'; params.push(limit); let d1Logs = []; try { d1Logs = await cf.d1Query(sql, params); } catch (e) { console.error('D1 logs query failed:', e.message); } // Also pull KV-backed logs written by child bots (works even if parent secret is missing) let kvLogs = []; try { kvLogs = await cf.collectKvLogs(botId, { level, limit }); } catch (e) { console.error('KV logs collect failed:', e.message); } // Merge + dedupe const merged = []; const seen = new Set(); for (const row of [...(d1Logs || []), ...kvLogs]) { const key = `${row.level}|${row.created_at}|${row.message}`; if (seen.has(key)) continue; seen.add(key); merged.push({ id: row.id, bot_id: botId, level: row.level, message: row.message, created_at: row.created_at, source: row.source || 'd1', }); } merged.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); const logs = merged.slice(0, limit); const countResult = await cf.d1Query('SELECT COUNT(*) as count FROM bot_logs WHERE bot_id = ?', [botId]).catch(() => []); const d1Count = readUserCount(countResult); // Prefer actual available log volume (D1 total or merged fetch size) const totalCount = Math.max(d1Count, merged.length, logs.length); return c.json({ logs, totalCount, shownCount: logs.length, sources: { d1: (d1Logs || []).length, kv: (kvLogs || []).length }, ingestReady: !!parsePlatformConfig(c.env?.PLATFORM_CONFIG), }); }); // Clear all logs for a bot app.delete('/api/bots/:id/logs', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); // Ensure bot_logs table exists (safe no-op if already exists) await cf.ensureBotLogsTable(); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const bots = await cf.d1Query('SELECT id FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); await cf.d1Execute('DELETE FROM bot_logs WHERE bot_id = ?', [botId]); // Also clear KV-backed logs (aggregated list + per-entry keys) try { await cf.kvDelete(`botlogs:${botId}`); const keys = await cf.kvListKeys(`botlog:${botId}:`, 2000); // Delete in small parallel batches for (let i = 0; i < keys.length; i += 20) { await Promise.all(keys.slice(i, i + 20).map((key) => cf.kvDelete(key).catch(() => null))); } } catch (e) { console.error('KV log clear failed:', e.message); } return c.json({ success: true, message: '✅ Logs cleared!' }); }); // ── Form Submissions inbox ────────────────────────────────── app.post('/api/bots/:id/submissions/ingest', async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); await cf.ensureFormSubmissionsTable(); const botId = parseInt(c.req.param('id')); let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); } const token = body?.token; const submission = body?.submission; if (!token || !submission || typeof submission !== 'object') { return c.json({ error: 'token and submission required' }, 400); } const bots = await cf.d1Query('SELECT bot_token FROM bots WHERE id = ?', [botId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); if (bots[0].bot_token !== token) return c.json({ error: 'Unauthorized' }, 403); const id = String(submission.id || '').trim() || (Date.now() + '-' + Math.random().toString(36).slice(2, 8)); const entry = { ...submission, id, bot_id: String(botId), telegram_user_id: String(submission.telegram_user_id || ''), chat_id: String(submission.chat_id || submission.telegram_user_id || ''), first_name: submission.first_name || null, last_name: submission.last_name || null, username: submission.username || null, keys: Array.isArray(submission.keys) ? submission.keys : [], data: submission.data && typeof submission.data === 'object' ? submission.data : {}, created_at: submission.created_at || new Date().toISOString(), }; // D1 (durable — what the dashboard prefers) try { await cf.d1Execute( `INSERT OR REPLACE INTO form_submissions (id, bot_id, telegram_user_id, chat_id, first_name, last_name, username, data_json, keys_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ entry.id, botId, entry.telegram_user_id, entry.chat_id, entry.first_name, entry.last_name, entry.username, JSON.stringify(entry.data), JSON.stringify(entry.keys), entry.created_at, ] ); } catch (e) { console.error('D1 submission ingest failed:', e.message); return c.json({ error: 'D1 write failed: ' + e.message }, 500); } // Skip parent KV mirror here — free plan has only 50 subrequests/invocation. // Child already keeps a local KV copy; dashboard reads D1 first. return c.json({ success: true, id }); }); app.get('/api/bots/:id/submissions', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); await cf.ensureFormSubmissionsTable(); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const url = new URL(c.req.url, 'http://localhost'); const limit = Math.min(parseInt(url.searchParams.get('limit') || '100', 10) || 100, 200); const bots = await cf.d1Query( 'SELECT id, bot_token, worker_url FROM bots WHERE id = ? AND user_id = ?', [botId, userId] ); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); const bot = bots[0]; const byId = new Map(); const sources = { d1: 0, worker: 0, kv: 0 }; // 1) D1 (primary — fewest subrequests) try { const rows = await cf.d1Query( 'SELECT * FROM form_submissions WHERE bot_id = ? ORDER BY created_at DESC LIMIT ?', [botId, limit] ); sources.d1 = (rows || []).length; for (const row of rows || []) { let data = {}; let keys = []; try { data = JSON.parse(row.data_json || '{}'); } catch (_) {} try { keys = JSON.parse(row.keys_json || '[]'); } catch (_) {} byId.set(String(row.id), { id: row.id, bot_id: botId, telegram_user_id: row.telegram_user_id, chat_id: row.chat_id, first_name: row.first_name, last_name: row.last_name, username: row.username, data, keys, created_at: row.created_at, source: 'd1', }); } } catch (e) { console.error('D1 submissions query failed:', e.message); } // 2/3) Only hit worker + KV if D1 is empty (saves free-plan subrequest budget) if (byId.size === 0) { const workerUrl = String(bot.worker_url || '').replace(/\/$/, ''); if (workerUrl && bot.bot_token) { try { const relayRes = await fetch(workerUrl + '/submissions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: bot.bot_token, action: 'list', limit }), }); const relayData = await relayRes.json().catch(() => ({})); if (relayRes.ok && Array.isArray(relayData.submissions)) { sources.worker = relayData.submissions.length; for (const s of relayData.submissions) { if (!s || !s.id) continue; byId.set(String(s.id), { ...s, source: 'worker' }); } } } catch (e) { console.error('worker submissions relay failed:', e.message); } } if (byId.size === 0) { try { const raw = await cf.kvGet(`botsubmissions:${botId}`); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { sources.kv = parsed.length; for (const s of parsed) { if (!s || !s.id) continue; byId.set(String(s.id), { ...s, source: 'kv' }); } } } } catch (e) { console.error('KV submissions read failed:', e.message); } } } const submissions = [...byId.values()] .sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))) .slice(0, limit); return c.json({ submissions, totalCount: submissions.length, sources, }); }); app.delete('/api/bots/:id/submissions', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); await cf.ensureFormSubmissionsTable(); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const bots = await cf.d1Query( 'SELECT id, bot_token, worker_url FROM bots WHERE id = ? AND user_id = ?', [botId, userId] ); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); const bot = bots[0]; try { await cf.d1Execute('DELETE FROM form_submissions WHERE bot_id = ?', [botId]); } catch (_) {} try { await cf.kvDelete(`botsubmissions:${botId}`); } catch (_) {} const workerUrl = String(bot.worker_url || '').replace(/\/$/, ''); if (workerUrl && bot.bot_token) { try { await fetch(workerUrl + '/submissions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: bot.bot_token, action: 'clear' }), }); } catch (_) {} } return c.json({ success: true, message: '✅ Submissions cleared!' }); }); app.delete('/api/bots/:id/submissions/:subId', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); await cf.ensureFormSubmissionsTable(); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const subId = String(c.req.param('subId') || '').trim(); if (!subId) return c.json({ error: 'Submission id required' }, 400); const bots = await cf.d1Query( 'SELECT id, bot_token, worker_url FROM bots WHERE id = ? AND user_id = ?', [botId, userId] ); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); const bot = bots[0]; try { await cf.d1Execute('DELETE FROM form_submissions WHERE bot_id = ? AND id = ?', [botId, subId]); } catch (_) {} try { const listKey = `botsubmissions:${botId}`; const raw = await cf.kvGet(listKey); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { const next = parsed.filter((s) => String(s?.id) !== subId); await cf.kvPut(listKey, JSON.stringify(next), 86400 * 90); } } await cf.kvDelete(`botsubmission:${botId}:${subId}`).catch(() => null); } catch (e) { console.error('KV submission delete failed:', e.message); } const workerUrl = String(bot.worker_url || '').replace(/\/$/, ''); if (workerUrl && bot.bot_token) { try { await fetch(workerUrl + '/submissions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: bot.bot_token, action: 'delete', id: subId }), }); } catch (_) {} } return c.json({ success: true, deleted: subId }); }); // Get log stats for a bot app.get('/api/bots/:id/logs/stats', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); await cf.ensureBotLogsTable(); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const bots = await cf.d1Query('SELECT id FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); let countResult = []; let levelCounts = []; try { countResult = await cf.d1Query('SELECT COUNT(*) as count FROM bot_logs WHERE bot_id = ?', [botId]); levelCounts = await cf.d1Query( 'SELECT level, COUNT(*) as count FROM bot_logs WHERE bot_id = ? GROUP BY level', [botId] ); } catch (e) { console.error('log stats d1 failed:', e.message); } let kvLogs = []; try { kvLogs = await cf.collectKvLogs(botId, { limit: 500 }); } catch (e) { console.error('log stats kv failed:', e.message); } const levelMap = {}; for (const row of levelCounts || []) { levelMap[row.level] = Number(row.count) || 0; } for (const row of kvLogs) { const lv = row.level || 'INFO'; levelMap[lv] = (levelMap[lv] || 0) + 1; } const levels = Object.keys(levelMap).map((level) => ({ level, count: levelMap[level] })); const totalLogs = Math.max(readUserCount(countResult), kvLogs.length, levels.reduce((s, l) => s + l.count, 0)); return c.json({ totalLogs, levels, ingestReady: !!parsePlatformConfig(c.env?.PLATFORM_CONFIG), }); }); // ============================================================ // BOT USERS + BROADCAST // ============================================================ // Child workers report users who messaged the bot app.post('/api/bots/:id/users/track', async (c) => { try { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized — PLATFORM_CONFIG missing on parent Worker' }, 400); const botId = parseInt(c.req.param('id')); if (!botId) return c.json({ error: 'Invalid bot id' }, 400); let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON body' }, 400); } const { token, telegram_user_id, chat_id, first_name, last_name, username, user } = body; const tgId = telegram_user_id || user?.id || user?.telegram_user_id; if (!token || !tgId) { return c.json({ error: 'token and telegram_user_id required' }, 400); } const bots = await cf.d1Query('SELECT bot_token FROM bots WHERE id = ?', [botId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); if (bots[0].bot_token !== token) return c.json({ error: 'Unauthorized' }, 403); const result = await cf.upsertBotUser(botId, { telegram_user_id: tgId, chat_id: chat_id || user?.chat_id || tgId, first_name: first_name || user?.first_name, last_name: last_name || user?.last_name, username: username || user?.username, }); try { await cf.upsertKvBotUser(botId, { telegram_user_id: tgId, chat_id: chat_id || user?.chat_id || tgId, first_name: first_name || user?.first_name, last_name: last_name || user?.last_name, username: username || user?.username, }); } catch (_) {} if (!result.ok) { return c.json({ error: 'Failed to save user: ' + (result.reason || 'unknown') }, 500); } return c.json({ success: true, telegram_user_id: result.telegram_user_id }); } catch (e) { console.error('users/track error:', e); return c.json({ error: e.message || 'Track failed' }, 500); } }); app.get('/api/bots/:id/users', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); await cf.ensureBotUsersTable(); await ensurePlatformConfigPersisted(c, cf); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const bots = await cf.d1Query( 'SELECT id, bot_token, worker_url FROM bots WHERE id = ? AND user_id = ?', [botId, userId] ); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); const bot = bots[0]; const url = new URL(c.req.url, 'http://localhost'); const limit = Math.min(parseInt(url.searchParams.get('limit') || '200'), 1000); const byId = new Map(); const sources = { d1: 0, kv: 0, worker: 0 }; // 1) Ask the deployed child worker first (KV binding list sees local writes) const workerUrl = String(bot.worker_url || '').replace(/\/$/, ''); if (workerUrl && bot.bot_token) { try { const relayRes = await fetch(workerUrl + '/subscribers', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: bot.bot_token }), }); const relayData = await relayRes.json().catch(() => ({})); if (relayRes.ok && Array.isArray(relayData.users)) { sources.worker = relayData.users.length; for (const u of relayData.users) { const id = String(u.telegram_user_id || u.id || '').trim(); if (!id) continue; byId.set(id, { ...u, telegram_user_id: id, bot_id: botId }); } } } catch (e) { console.error('worker subscribers relay failed:', e.message); } } // 2) D1 try { const d1Users = await cf.d1Query( 'SELECT * FROM bot_users WHERE bot_id = ? ORDER BY last_seen_at DESC LIMIT ?', [botId, limit] ); sources.d1 = (d1Users || []).length; for (const u of d1Users || []) { const id = String(u.telegram_user_id || '').trim(); if (!id) continue; byId.set(id, { ...(byId.get(id) || {}), ...u, bot_id: botId }); } } catch (e) { console.error('D1 users query failed:', e.message); } // 3) Parent KV REST (backup only if worker/D1 found nobody) let kvUsers = []; if (byId.size === 0) { try { // Cheap path: one GET of aggregated map (no key listing) const raw = await cf.kvGet(`botusers:${botId}`); if (raw) { const parsed = JSON.parse(raw); const list = Array.isArray(parsed) ? parsed : (parsed && typeof parsed === 'object' ? ((parsed.telegram_user_id || parsed.id) ? [parsed] : Object.values(parsed)) : []); kvUsers = list; sources.kv = list.length; for (const u of list) { const id = String(u.telegram_user_id || u.id || '').trim(); if (!id) continue; byId.set(id, { ...(byId.get(id) || {}), ...u, telegram_user_id: id, bot_id: botId }); } } } catch (e) { console.error('KV users collect failed:', e.message); } } const users = Array.from(byId.values()) .sort((a, b) => String(b.last_seen_at || '').localeCompare(String(a.last_seen_at || ''))) .slice(0, limit); // Persist lightly (one map write + a few D1 upserts — avoid subrequest storms) if (users.length > 0) { try { const map = {}; for (const u of users) { const id = String(u.telegram_user_id || '').trim(); if (id) map[id] = u; } await cf.kvPut(`botusers:${botId}`, JSON.stringify(map), 86400 * 180); } catch (e) { console.error('botusers persist failed:', e.message); } for (const u of users.slice(0, 10)) { try { await cf.upsertBotUser(botId, u); } catch (_) {} } } let d1Count = 0; try { const r = await cf.d1Query('SELECT COUNT(*) as count FROM bot_users WHERE bot_id = ?', [botId]); d1Count = Number(r?.[0]?.count) || 0; } catch (_) {} return c.json({ users, totalCount: Math.max(users.length, d1Count), sources: { d1: d1Count, kv: sources.kv, worker: sources.worker }, ingestReady: !!parsePlatformConfig(c.env?.PLATFORM_CONFIG), }); }); app.post('/api/bots/:id/broadcast', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const { text, photo_url } = await c.req.json(); if (!text || !String(text).trim()) { return c.json({ error: 'Message text is required' }, 400); } if (String(text).length > 4096) { return c.json({ error: 'Message too long (max 4096 characters)' }, 400); } const bots = await cf.d1Query('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); const bot = bots[0]; const token = String(bot.bot_token || '').trim(); if (!token) { return c.json({ error: 'Bot token missing. Edit the bot token, then Deploy again.' }, 400); } const photo = photo_url ? String(photo_url).trim() : ''; if (photo && !/^https?:\/\//i.test(photo)) { return c.json({ error: 'Photo URL must start with http:// or https://' }, 400); } const workerUrl = String(bot.worker_url || '').replace(/\/$/, ''); // Fast path: one subrequest to the child bot — it resolves subscribers + sends // (Avoids parent hitting "Too many subrequests" from KV list/get storms) if (workerUrl) { try { const relayRes = await fetch(workerUrl + '/broadcast', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, text: String(text), photo_url: photo || undefined, all: true, }), }); const relayData = await relayRes.json().catch(() => ({})); if (relayRes.status === 401) { return c.json({ error: 'Deployed bot token does not match the dashboard token. Click 🚀 Deploy again, then retry Broadcast.', }, 400); } if (relayRes.ok && (relayData.ok || relayData.sent != null)) { const sent = Number(relayData.sent) || 0; const failed = Number(relayData.failed) || 0; const errors = Array.isArray(relayData.errors) ? relayData.errors.slice(0, 5) : []; return c.json({ success: sent > 0, total: relayData.total || (sent + failed), sent, failed, errors, via: 'worker', truncated: !!relayData.truncated, message: `Broadcast finished: ${sent} sent, ${failed} failed` + (errors.length ? ` — ${errors[0]}` : '') + (relayData.truncated ? ' (batch capped; send again for more)' : ''), }); } // Old bot without /broadcast support — fall through to light direct send } catch (e) { console.error('broadcast worker relay failed:', e.message); } } // Lightweight fallback: D1 / botusers map + direct Telegram let audience = []; try { audience = await cf.d1Query( 'SELECT telegram_user_id, chat_id, first_name, last_name, username FROM bot_users WHERE bot_id = ? ORDER BY id ASC LIMIT 40', [botId] ); } catch (e) { console.error('broadcast d1 audience failed:', e.message); } if (!audience.length) { try { const raw = await cf.kvGet(`botusers:${botId}`); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) audience = parsed; else if (parsed && typeof parsed === 'object') { audience = (parsed.telegram_user_id || parsed.id) ? [parsed] : Object.values(parsed); } } } catch (_) {} } if (!audience.length) { return c.json({ error: 'No subscribers found. Open Broadcast once (to load users), Redeploy the bot, then try again.', }, 400); } const personalize = (tpl, u) => { const first = u.first_name || ''; const last = u.last_name || ''; const id = String(u.telegram_user_id || u.id || ''); const map = { '{first_name}': first, '{last_name}': last, '{username}': u.username || first || 'User', '{user_id}': id, '{message}': '', '{chat_title}': first || 'Chat', '{chat_id}': String(u.chat_id || id), '{date}': new Date().toLocaleString(), }; return String(tpl).replace(/\{first_name\}|\{last_name\}|\{username\}|\{user_id\}|\{message\}|\{chat_title\}|\{chat_id\}|\{date\}/g, (m) => map[m] || ''); }; const targets = audience.slice(0, 40); let sent = 0; let failed = 0; const errors = []; for (const u of targets) { const chatId = String(u.chat_id || u.telegram_user_id || '').trim(); if (!chatId || chatId === 'null' || chatId === 'undefined') { failed += 1; continue; } try { const personalized = personalize(text, u); const endpoint = photo ? 'sendPhoto' : 'sendMessage'; const body = photo ? { chat_id: chatId, photo, caption: personalized } : { chat_id: chatId, text: personalized, disable_web_page_preview: true }; const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await res.json(); if (data.ok) sent += 1; else { failed += 1; if (errors.length < 5) errors.push(data.description || 'send failed'); } } catch (e) { failed += 1; if (errors.length < 5) errors.push(e.message); } } return c.json({ success: sent > 0, total: targets.length, sent, failed, errors, via: 'direct', message: `Broadcast finished: ${sent} sent, ${failed} failed` + (errors.length ? ` — ${errors[0]}` : ''), }); }); // Get database storage stats (approximate) app.get('/api/db/stats', authMiddleware, async (c) => { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); await cf.ensureBotLogsTable(); await ensurePlatformConfigPersisted(c, cf); try { const userId = c.get('userId'); let botCount = 0, ruleCount = 0, logCountD1 = 0, userCount = 0; let bots = []; try { bots = await cf.d1Query('SELECT id FROM bots WHERE user_id = ?', [userId]); botCount = (bots || []).length; } catch (e) { console.error('bots count:', e.message); } try { const r = await cf.d1Query( 'SELECT COUNT(*) as count FROM rules WHERE bot_id IN (SELECT id FROM bots WHERE user_id = ?)', [userId] ); ruleCount = r[0]?.count || 0; } catch (e) { // Fallback: all rules (older DBs / query quirks) try { const r = await cf.d1Query('SELECT COUNT(*) as count FROM rules'); ruleCount = r[0]?.count || 0; } catch (e2) { console.error('rules count:', e2.message); } } try { const r = await cf.d1Query('SELECT COUNT(*) as count FROM bot_logs'); logCountD1 = Number(r[0]?.count) || 0; } catch (e) { console.error('bot_logs count:', e.message); } try { const r = await cf.d1Query('SELECT COUNT(*) as count FROM users'); userCount = r[0]?.count || 0; } catch (e) { console.error('users count:', e.message); } // KV is the primary log store — count aggregated botlogs:{id} lists (cheap: 1 GET per bot) let logCountKv = 0; try { for (const b of (bots || []).slice(0, 50)) { const raw = await cf.kvGet(`botlogs:${b.id}`); if (!raw) continue; try { const list = JSON.parse(raw); if (Array.isArray(list)) logCountKv += list.length; } catch (_) {} } } catch (e) { console.error('kv logs count:', e.message); } const logCount = Math.max(logCountD1, logCountKv); const estimatedBytes = userCount * 256 + botCount * 512 + ruleCount * 384 + logCount * 192; return c.json({ bots: botCount, rules: ruleCount, logs: logCount, logsD1: logCountD1, logsKv: logCountKv, users: userCount, estimatedBytes, estimatedMB: (estimatedBytes / (1024 * 1024)).toFixed(2), }); } catch (e) { return c.json({ error: e.message }, 500); } }); // ============================================================ // CODE GENERATOR — self-contained Worker (no npm imports) // ============================================================ function generateBotScript(botRules) { const rulesJSON = JSON.stringify(botRules); return `const rules = ${rulesJSON}; // ── Logging helpers ── const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARNING: 2, ERROR: 3 }; async function log(level, message, env, meta) { if (!env) return; const logEnabled = env.LOG_ENABLED !== 'false'; if (!logEnabled) return; const logLevel = (LOG_LEVELS[env.LOG_LEVEL] !== undefined ? env.LOG_LEVEL : 'INFO'); const levelNum = LOG_LEVELS[level] !== undefined ? LOG_LEVELS[level] : LOG_LEVELS.INFO; const configNum = LOG_LEVELS[logLevel] !== undefined ? LOG_LEVELS[logLevel] : LOG_LEVELS.INFO; // Still persist subscriber even if this log level is filtered const user = meta && meta.user && meta.user.id ? meta.user : null; const chatId = meta && meta.chatId != null ? meta.chatId : (user ? user.id : null); if (user) { try { await trackUser(env, user, chatId); } catch (_) {} } if (levelNum < configNum) return; const botId = String(env.BOT_ID || '0'); const createdAt = new Date().toISOString(); const entry = { level: level, message: String(message).slice(0, 2000), created_at: createdAt, bot_id: botId, }; if (user) { entry.user = { id: user.id, telegram_user_id: String(user.id), first_name: user.first_name || null, last_name: user.last_name || null, username: user.username || null, }; entry.chat_id = String(chatId || user.id); } // 1) Always try local KV first (works even when parent PLATFORM_CONFIG is missing) if (env.BOT_DATA) { try { // Aggregated list — one key the dashboard can read fully (avoids KV list lag / missing entries) const listKey = 'botlogs:' + botId; let list = []; try { const prev = await env.BOT_DATA.get(listKey); if (prev) { const parsed = JSON.parse(prev); if (Array.isArray(parsed)) list = parsed; } } catch (_) {} list.unshift(entry); await env.BOT_DATA.put(listKey, JSON.stringify(list.slice(0, 500)), { expirationTtl: 86400 * 14 }); } catch (e) { console.error('KV log list write failed:', e.message); } try { // Keep a per-entry key as backup const key = 'botlog:' + botId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2, 8); await env.BOT_DATA.put(key, JSON.stringify(entry), { expirationTtl: 86400 * 14 }); } catch (e) { console.error('KV log write failed:', e.message); } } // 2) Also mirror to parent Worker (D1) when possible const parentUrl = env.PARENT_URL || ''; const botToken = env.BOT_TOKEN || ''; if (!parentUrl || !botToken) return; try { const payload = { token: botToken, level: entry.level, message: entry.message, }; if (user) { payload.user = entry.user; payload.chat_id = entry.chat_id; } const res = await fetch(parentUrl + '/api/logs/' + botId, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) { console.error('log parent error:', res.status, await res.text()); } } catch (e) { console.error('log parent request failed:', e.message); } } async function trackUser(env, from, chatId) { if (!from || from.id == null || from.id === '') return; const botId = String(env.BOT_ID || '0'); const payload = { telegram_user_id: String(from.id), chat_id: String(chatId != null ? chatId : from.id), first_name: from.first_name || null, last_name: from.last_name || null, username: from.username || null, last_seen_at: new Date().toISOString(), }; // 1) KV — per-user key (authoritative, no cross-user races) if (env.BOT_DATA) { try { await env.BOT_DATA.put( 'botuser:' + botId + ':' + payload.telegram_user_id, JSON.stringify(payload), { expirationTtl: 86400 * 180 } ); } catch (e) { console.error('KV user track failed:', e.message); } // Best-effort map merge (may race; per-user key above is what matters) try { const listKey = 'botusers:' + botId; let map = {}; try { const prev = await env.BOT_DATA.get(listKey); if (prev) { const parsed = JSON.parse(prev); if (Array.isArray(parsed)) { for (const u of parsed) { const id = String(u && u.telegram_user_id || ''); if (id) map[id] = u; } } else if (parsed && typeof parsed === 'object') { if (parsed.telegram_user_id || parsed.id) { map[String(parsed.telegram_user_id || parsed.id)] = parsed; } else { map = parsed; } } } } catch (_) {} map[payload.telegram_user_id] = { ...(map[payload.telegram_user_id] || {}), ...payload }; await env.BOT_DATA.put(listKey, JSON.stringify(map), { expirationTtl: 86400 * 180 }); } catch (e) { console.error('KV user list write failed:', e.message); } } // 2) Parent D1 + parent KV (best effort — do not block bot replies) if (!env.PARENT_URL || !env.BOT_TOKEN) return; try { const res = await fetch(env.PARENT_URL + '/api/bots/' + botId + '/users/track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: env.BOT_TOKEN, ...payload, }), }); if (!res.ok) { console.error('trackUser parent failed:', res.status, await res.text()); } } catch (e) { console.error('trackUser request error:', e.message); } } function stateKey(userId) { return 'state:user:' + String(userId); } async function getUserState(env, userId) { if (!env.BOT_DATA || !userId) return ''; try { return (await env.BOT_DATA.get(stateKey(userId))) || ''; } catch { return ''; } } async function setUserState(env, userId, state) { if (!env.BOT_DATA || !userId) return false; const value = String(state || '').trim(); if (!value) { await env.BOT_DATA.delete(stateKey(userId)); return true; } await env.BOT_DATA.put(stateKey(userId), value, { expirationTtl: 86400 * 7 }); return true; } async function saveFormSubmission(env, opts) { opts = opts || {}; const from = opts.from; const chatId = opts.chatId; const data = opts.data; const keys = opts.keys; if (!env.BOT_DATA || !from || from.id == null || from.id === '') return null; const botId = String(env.BOT_ID || '0'); const id = Date.now() + '-' + from.id + '-' + Math.random().toString(36).slice(2, 8); const entry = { id: id, bot_id: botId, telegram_user_id: String(from.id), chat_id: String(chatId != null ? chatId : from.id), first_name: from.first_name || null, last_name: from.last_name || null, username: from.username || null, keys: Array.isArray(keys) ? keys : Object.keys(data || {}), data: data && typeof data === 'object' ? data : {}, created_at: new Date().toISOString(), }; const listKey = 'botsubmissions:' + botId; let list = []; try { const prev = await env.BOT_DATA.get(listKey); if (prev) { const parsed = JSON.parse(prev); if (Array.isArray(parsed)) list = parsed; } } catch (_) {} list.unshift(entry); await env.BOT_DATA.put(listKey, JSON.stringify(list.slice(0, 200)), { expirationTtl: 86400 * 90 }); // Mirror to parent D1 so the dashboard inbox fills (skip extra per-entry KV write to save subrequests) const parentUrl = env.PARENT_URL || ''; const botToken = env.BOT_TOKEN || ''; if (parentUrl && botToken) { try { const res = await fetch(parentUrl + '/api/bots/' + botId + '/submissions/ingest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: botToken, submission: entry }), }); if (!res.ok) { console.error('submission parent ingest failed:', res.status, await res.text()); } } catch (e) { console.error('submission parent ingest request failed:', e.message); } } return entry; } async function tg(env, method, body) { if (!env.BOT_TOKEN) { throw new Error('BOT_TOKEN is not configured'); } try { const res = await fetch('https://api.telegram.org/bot' + env.BOT_TOKEN + '/' + method, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), }); const data = await res.json(); if (!data.ok) { console.error('Telegram API error:', method, data.description || data); } return data; } catch (e) { console.error('Telegram API request failed:', method, e.message); return { ok: false, error: e.message }; } } function parseParams(rule) { if (!rule.action_params) return {}; if (typeof rule.action_params === 'string') { try { return JSON.parse(rule.action_params); } catch { return {}; } } return rule.action_params; } function getCommand(text) { if (!text || typeof text !== 'string' || !text.startsWith('/')) return null; const part = text.trim().split(/\\s+/)[0]; return part.split('@')[0].slice(1).toLowerCase(); } async function reply(env, chatId, text, extra) { return tg(env, 'sendMessage', { chat_id: chatId, text: text || 'Hello!', disable_web_page_preview: true, ...(extra || {}), }); } function replaceVariables(text, update) { if (!text || typeof text !== 'string') return text || ''; const msg = update.message || update.edited_message || update.callback_query?.message || {}; const from = update.message?.from || update.edited_message?.from || update.callback_query?.from || {}; const chat = msg.chat || {}; const map = { '{first_name}': from.first_name || '', '{last_name}': from.last_name || '', '{username}': from.username || from.first_name || 'User', '{user_id}': String(from.id || ''), '{message}': msg.text || '', '{chat_title}': chat.title || chat.first_name || 'Chat', '{chat_id}': String(chat.id || ''), '{date}': msg.date ? new Date(msg.date * 1000).toLocaleString() : '', }; return text.replace(/{first_name}|{last_name}|{username}|{user_id}|{message}|{chat_title}|{chat_id}|{date}/g, function(m) { return map[m] || ''; }); } function personalizeBroadcast(text, user) { if (!text || typeof text !== 'string') return text || ''; const u = user || {}; const first = u.first_name || ''; const last = u.last_name || ''; const id = String(u.telegram_user_id || u.id || ''); const chatId = String(u.chat_id || id); const map = { '{first_name}': first, '{last_name}': last, '{username}': u.username || first || 'User', '{user_id}': id, '{message}': '', '{chat_title}': first || 'Chat', '{chat_id}': chatId, '{date}': new Date().toLocaleString(), }; return text.replace(/{first_name}|{last_name}|{username}|{user_id}|{message}|{chat_title}|{chat_id}|{date}/g, function(m) { return map[m] || ''; }); } async function executeAction(env, update, actionType, params) { const message = update.message || update.edited_message || update.callback_query?.message; const from = update.message?.from || update.edited_message?.from || update.callback_query?.from; const chatId = message?.chat?.id; if (!chatId) return; // Log every action execution await log('INFO', 'Action: ' + actionType + ' triggered' + (from ? ' by ' + (from.username || from.first_name || from.id) : ''), env, { user: from, chatId, }); switch (actionType) { case 'send_message': { await reply(env, chatId, replaceVariables(params.text || 'Hello!', update), {}); break; } case 'send_photo': { const photo = params.photo_url || params.file_id; if (!photo) { await reply(env, chatId, '⚠️ No photo URL or file ID provided for this action.'); break; } await tg(env, 'sendPhoto', { chat_id: chatId, photo, caption: replaceVariables(params.caption || '', update), }); break; } case 'send_document': { const doc = params.document_url || params.file_id; if (!doc) { await reply(env, chatId, '⚠️ No document URL or file ID provided for this action.'); break; } await tg(env, 'sendDocument', { chat_id: chatId, document: doc, caption: replaceVariables(params.document_caption || '', update), }); break; } case 'show_keyboard': { const buttons = params.buttons || []; const isInline = params.keyboard_type === 'inline'; const reply_markup = isInline ? { inline_keyboard: buttons.map((b) => [{ text: b, callback_data: String(b).slice(0, 64) }]) } : { keyboard: buttons.map((b) => [{ text: b }]), resize_keyboard: true, one_time_keyboard: true, }; await reply(env, chatId, params.text || 'Choose an option:', { reply_markup }); break; } case 'store_data': { const key = 'user:' + (from?.id || 'unknown') + ':' + (params.key || 'data'); const value = params.value || message?.text || ''; if (env.BOT_DATA) { await env.BOT_DATA.put(key, value, { expirationTtl: 86400 * 30 }); await reply(env, chatId, '✅ Data saved!'); } else { await reply(env, chatId, '⚠️ Storage is not configured for this bot.'); } break; } case 'fetch_api': try { // Normalize common paste mistakes (Windows backslashes, missing scheme) let apiUrl = String(params.api_url || '').trim().replace(/\\\\/g, '/'); if (apiUrl && !/^https?:\\/\\//i.test(apiUrl) && /^[a-z0-9.-]+\\//i.test(apiUrl)) { apiUrl = 'https://' + apiUrl; } if (!/^https?:\\/\\//i.test(apiUrl)) { await reply(env, chatId, '❌ API URL must start with https:// (example: https://official-joke-api.appspot.com/random_joke)'); break; } const apiRes = await fetch(apiUrl, { method: params.method || 'GET', headers: { 'Content-Type': 'application/json' }, body: params.method === 'POST' ? (params.body || '{}') : undefined, }); const data = await apiRes.json(); const mode = String(params.response_mode || 'raw').toLowerCase(); const template = String(params.response_template || '').trim(); let out = ''; if (mode === 'template' && template) { const tgKeys = { first_name:1, last_name:1, username:1, user_id:1, message:1, chat_title:1, chat_id:1, date:1 }; out = template.replace(/\\{([a-zA-Z0-9_.]+)\\}/g, function(match, key) { if (tgKeys[key]) return match; // leave for replaceVariables if (key === 'json') { try { return JSON.stringify(data, null, 2); } catch (_) { return String(data); } } // Nested path support: {setup}, {user.name}, etc. const parts = String(key).split('.'); let cur = data; for (let i = 0; i < parts.length; i++) { if (cur == null || typeof cur !== 'object') { cur = undefined; break; } cur = cur[parts[i]]; } if (cur === undefined || cur === null) return ''; if (typeof cur === 'object') { try { return JSON.stringify(cur); } catch (_) { return String(cur); } } return String(cur); }); out = replaceVariables(out, update); } else { out = JSON.stringify(data, null, 2); } await reply(env, chatId, String(out).slice(0, 4000)); } catch (e) { await reply(env, chatId, '❌ API request failed: ' + e.message); } break; case 'forward': if (params.admin_id && message?.message_id) { await tg(env, 'forwardMessage', { chat_id: params.admin_id, from_chat_id: chatId, message_id: message.message_id, }); } else { await reply(env, chatId, 'Admin ID not configured.'); } break; case 'set_state': { if (!from?.id) break; const nextState = params.state || params.value || ''; const ok = await setUserState(env, from.id, nextState); if (params.text) { await reply(env, chatId, replaceVariables(params.text, update), {}); } else if (!ok) { await reply(env, chatId, '⚠️ State storage is not configured for this bot (redeploy with KV).'); } else { await log('INFO', 'State set for user ' + from.id + ' => ' + nextState, env); } break; } case 'clear_state': { if (!from?.id) break; await setUserState(env, from.id, ''); if (params.text) { await reply(env, chatId, replaceVariables(params.text, update), {}); } else { await log('INFO', 'State cleared for user ' + from.id, env); } break; } case 'send_form_summary': { if (!from?.id) break; if (!env.BOT_DATA) { await reply(env, chatId, '⚠️ Storage is not configured for this bot (redeploy with KV).'); break; } const keys = String(params.keys || '') .split(/[,\\n]+/) .map(function(k) { return String(k || '').trim(); }) .filter(Boolean); const storeAs = String(params.store_current_as || '').trim(); if (storeAs) { const curVal = message?.text || message?.caption || ''; await env.BOT_DATA.put('user:' + from.id + ':' + storeAs, curVal, { expirationTtl: 86400 * 30 }); if (keys.indexOf(storeAs) === -1) keys.push(storeAs); } if (!keys.length) { await reply(env, chatId, '❌ Form summary has no data keys configured.'); break; } const formData = {}; for (let i = 0; i < keys.length; i++) { const k = keys[i]; try { formData[k] = (await env.BOT_DATA.get('user:' + from.id + ':' + k)) || ''; } catch (_) { formData[k] = ''; } } let template = String(params.template || '').trim(); if (!template) { const lines = ['📋 Form submission', 'From: {first_name} (@{username})', 'ID: {user_id}', '']; for (let i = 0; i < keys.length; i++) { lines.push(keys[i] + ': {' + keys[i] + '}'); } template = lines.join('\\n'); } const tgKeys = { first_name:1, last_name:1, username:1, user_id:1, message:1, chat_title:1, chat_id:1, date:1 }; let out = template.replace(/\\{([a-zA-Z0-9_.]+)\\}/g, function(match, key) { if (tgKeys[key]) return match; if (Object.prototype.hasOwnProperty.call(formData, key)) { return formData[key] == null ? '' : String(formData[key]); } return ''; }); out = replaceVariables(out, update); out = String(out).slice(0, 4000); // Persist for dashboard Submissions inbox (before clearing keys) try { const saved = await saveFormSubmission(env, { from: from, chatId: chatId, data: formData, keys: keys, }); if (saved && saved.id) { await log('INFO', 'Submission saved: ' + saved.id, env, { user: from, chatId: chatId }); } else { await log('WARNING', 'Submission not saved (missing storage or user id)', env, { user: from, chatId: chatId }); } } catch (e) { console.error('saveFormSubmission failed:', e.message); await log('ERROR', 'Submission save failed: ' + (e.message || e), env, { user: from, chatId: chatId }); } const sendTo = String(params.send_to || 'admin').toLowerCase(); const adminId = params.admin_id; if (sendTo === 'admin' || sendTo === 'both') { if (adminId) { await reply(env, adminId, out); } else { await reply(env, chatId, '❌ Admin ID not configured for form summary.'); } } if (sendTo === 'user' || sendTo === 'both') { await reply(env, chatId, out); } const confirm = String(params.confirm_text || '').trim(); if (confirm && sendTo === 'admin') { await reply(env, chatId, replaceVariables(confirm, update)); } const clearKeys = String(params.clear_keys == null ? 'yes' : params.clear_keys).toLowerCase() !== 'no'; const clearStateFlag = String(params.clear_state == null ? 'yes' : params.clear_state).toLowerCase() !== 'no'; if (clearKeys) { for (let i = 0; i < keys.length; i++) { try { await env.BOT_DATA.delete('user:' + from.id + ':' + keys[i]); } catch (_) {} } } if (clearStateFlag) { await setUserState(env, from.id, ''); } await log('INFO', 'Form summary sent for user ' + from.id + ' (' + keys.join(',') + ')', env); break; } default: await reply(env, chatId, 'Unknown action: ' + actionType); } } async function ruleMatches(rule, update, env, cachedState) { const trigger = String(rule.trigger_type || '').trim(); const value = String(rule.trigger_value || '').trim(); const matchType = String(rule.match_type || 'contains').trim().toLowerCase(); const message = update.message || update.edited_message; const from = update.message?.from || update.edited_message?.from || update.callback_query?.from; function textMatches(haystack, needle) { if (!needle) return true; const h = String(haystack || ''); const n = String(needle || ''); if (matchType === 'exact') return h.toLowerCase() === n.toLowerCase(); if (matchType === 'starts_with') return h.toLowerCase().startsWith(n.toLowerCase()); if (matchType === 'regex') { try { return new RegExp(n, 'i').test(h); } catch (e) { console.error('Invalid regex in rule:', n, e.message); return false; } } return h.toLowerCase().includes(n.toLowerCase()); } if (trigger === 'command') { const text = message?.text || message?.caption || ''; const cmd = getCommand(text); const wanted = (value.replace(/^\\/+/, '') || 'start').toLowerCase(); return !!cmd && cmd === wanted; } if (trigger === 'message') { const text = message?.text || ''; if (!message || message.text == null) return false; if (!value) return true; return textMatches(text, value); } if (trigger === 'callback') { const data = update.callback_query?.data || ''; if (!update.callback_query) return false; if (!value) return true; return textMatches(data, value); } if (trigger === 'new_member') { return !!(message?.new_chat_members && message.new_chat_members.length); } if (trigger === 'state') { if (!value) return false; const current = cachedState !== undefined ? cachedState : await getUserState(env, from?.id); return String(current || '') === value; } return false; } async function handleUpdate(update, env) { const msg = update.message || update.edited_message || {}; const cb = update.callback_query || {}; const from = msg.from || cb.from || {}; const chatId = msg.chat?.id || cb.message?.chat?.id || from.id; const userName = from.username || from.first_name || from.id || 'unknown'; const text = msg.text || msg.caption || cb.data || '(no text)'; const logMeta = { user: from, chatId }; // Track immediately (KV), independent of log level filtering await trackUser(env, from, chatId); await log('INFO', 'Update from @' + userName + ': ' + text.slice(0, 200), env, logMeta); const userState = await getUserState(env, from.id); const ordered = [...rules].sort((a, b) => (Number(a.priority) || 0) - (Number(b.priority) || 0)); const stateRules = ordered.filter((r) => String(r.trigger_type) === 'state'); const otherRules = ordered.filter((r) => String(r.trigger_type) !== 'state'); let matched = 0; // If the user is in a form state, prefer state rules and skip other triggers if (userState && stateRules.length) { for (const rule of stateRules) { if (!(await ruleMatches(rule, update, env, userState))) continue; matched += 1; await log('DEBUG', 'State rule matched: ' + (rule.trigger_value || ''), env, logMeta); await executeAction(env, update, rule.action_type, parseParams(rule)); } if (matched > 0) { if (update.callback_query?.id) { await tg(env, 'answerCallbackQuery', { callback_query_id: update.callback_query.id }); } await log('INFO', 'Handled update via state: ' + matched + ' rule(s)', env, logMeta); return matched; } } for (const rule of otherRules) { if (!(await ruleMatches(rule, update, env, userState))) continue; matched += 1; await log('DEBUG', 'Rule matched: ' + (rule.trigger_type || '?') + ' = ' + (rule.trigger_value || ''), env, logMeta); await executeAction(env, update, rule.action_type, parseParams(rule)); } if (matched === 0) { await log('WARNING', 'No rules matched for update from @' + userName, env, logMeta); } if (update.callback_query?.id) { await tg(env, 'answerCallbackQuery', { callback_query_id: update.callback_query.id }); } await log('INFO', 'Handled update: ' + matched + ' rule(s) matched', env, logMeta); return matched; } export default { async fetch(request, env) { const url = new URL(request.url); const path = (url.pathname.replace(/\\/+$/, '') || '/'); if (request.method === 'GET' && (path === '/health' || path === '/')) { return Response.json({ ok: true, hasToken: Boolean(env.BOT_TOKEN), rules: rules.length, service: 'telegram-bot', engine: 'HG-TeleFlare v1.4.0', author: 'Hamed Gharghi', features: ['submissions', 'broadcast', 'forms'], build: 'submissions-inbox', }, { headers: { 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store', }, }); } if (request.method === 'POST' && (path === '/webhook' || path === '/')) { try { const update = await request.json(); await log('INFO', 'Webhook received', env); const matched = await handleUpdate(update, env); return Response.json({ ok: true, matched }); } catch (err) { console.error('Bot error:', err); await log('ERROR', 'Unhandled error: ' + (err.message || err), env); return Response.json({ ok: false, error: String(err.message || err) }, { status: 500 }); } } // Parent dashboard asks this bot for subscribers (KV binding list — sees local writes) if (request.method === 'POST' && path === '/subscribers') { try { const body = await request.json(); if (!body || body.token !== env.BOT_TOKEN) { return Response.json({ ok: false, error: 'Unauthorized' }, { status: 401 }); } if (!env.BOT_DATA) { return Response.json({ ok: true, users: [], error: 'BOT_DATA missing' }); } const botId = String(env.BOT_ID || '0'); const byId = {}; const remember = (parsed) => { if (!parsed) return; const tgId = String(parsed.telegram_user_id || parsed.id || '').trim(); if (!tgId || tgId === 'null' || tgId === 'undefined') return; byId[tgId] = { telegram_user_id: tgId, chat_id: String(parsed.chat_id || (byId[tgId] && byId[tgId].chat_id) || tgId), first_name: parsed.first_name || (byId[tgId] && byId[tgId].first_name) || null, last_name: parsed.last_name || (byId[tgId] && byId[tgId].last_name) || null, username: parsed.username || (byId[tgId] && byId[tgId].username) || null, last_seen_at: parsed.last_seen_at || parsed.created_at || (byId[tgId] && byId[tgId].last_seen_at) || null, }; }; // 1) Per-user keys via Workers KV list (most reliable) let cursor; do { const page = await env.BOT_DATA.list({ prefix: 'botuser:' + botId + ':', limit: 1000, cursor, }); for (const k of (page.keys || [])) { try { const raw = await env.BOT_DATA.get(k.name); if (raw) remember(JSON.parse(raw)); } catch (_) {} } cursor = page.list_complete ? null : page.cursor; } while (cursor); // 2) Aggregated map try { const rawMap = await env.BOT_DATA.get('botusers:' + botId); if (rawMap) { const parsed = JSON.parse(rawMap); if (Array.isArray(parsed)) parsed.forEach(remember); else if (parsed && typeof parsed === 'object') { if (parsed.telegram_user_id || parsed.id) remember(parsed); else Object.values(parsed).forEach(remember); } } } catch (_) {} // 3) Recover from log list try { const rawLogs = await env.BOT_DATA.get('botlogs:' + botId); if (rawLogs) { const list = JSON.parse(rawLogs); if (Array.isArray(list)) { for (const entry of list) { if (entry && entry.user) { remember({ ...entry.user, telegram_user_id: entry.user.telegram_user_id || entry.user.id, chat_id: entry.chat_id || entry.user.id, last_seen_at: entry.created_at, }); } } } } } catch (_) {} // Persist a clean map for the parent try { await env.BOT_DATA.put('botusers:' + botId, JSON.stringify(byId), { expirationTtl: 86400 * 180 }); } catch (_) {} const users = Object.values(byId); return Response.json({ ok: true, users, totalCount: users.length, source: 'worker' }); } catch (err) { return Response.json({ ok: false, error: String(err.message || err) }, { status: 500 }); } } // Parent dashboard: list / clear / delete form submissions if (request.method === 'POST' && path === '/submissions') { try { const body = await request.json(); if (!body || body.token !== env.BOT_TOKEN) { return Response.json({ ok: false, error: 'Unauthorized' }, { status: 401 }); } if (!env.BOT_DATA) { return Response.json({ ok: true, submissions: [], error: 'BOT_DATA missing' }); } const botId = String(env.BOT_ID || '0'); const listKey = 'botsubmissions:' + botId; const action = String(body.action || 'list').toLowerCase(); async function readList() { try { const raw = await env.BOT_DATA.get(listKey); if (!raw) return []; const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch (_) { return []; } } if (action === 'clear') { await env.BOT_DATA.delete(listKey); return Response.json({ ok: true, cleared: true }); } if (action === 'delete') { const delId = String(body.id || '').trim(); if (!delId) return Response.json({ ok: false, error: 'id required' }, { status: 400 }); const list = (await readList()).filter(function(s) { return String(s && s.id) !== delId; }); await env.BOT_DATA.put(listKey, JSON.stringify(list.slice(0, 200)), { expirationTtl: 86400 * 90 }); try { await env.BOT_DATA.delete('botsubmission:' + botId + ':' + delId); } catch (_) {} return Response.json({ ok: true, deleted: delId }); } const limit = Math.min(parseInt(body.limit || '100', 10) || 100, 200); const submissions = (await readList()).slice(0, limit); return Response.json({ ok: true, submissions: submissions, totalCount: submissions.length, source: 'worker' }); } catch (err) { return Response.json({ ok: false, error: String(err.message || err) }, { status: 500 }); } } // Parent dashboard relays broadcasts through this bot (uses the live BOT_TOKEN) if (request.method === 'POST' && path === '/broadcast') { try { const body = await request.json(); if (!body || body.token !== env.BOT_TOKEN) { return Response.json({ ok: false, error: 'Unauthorized' }, { status: 401 }); } const text = String(body.text || '').trim(); const photo = body.photo_url ? String(body.photo_url).trim() : ''; if (!text) return Response.json({ ok: false, error: 'text required' }, { status: 400 }); const botId = String(env.BOT_ID || '0'); const byId = {}; const remember = (parsed) => { if (!parsed) return; const tgId = String(parsed.telegram_user_id || parsed.id || '').trim(); if (!tgId || tgId === 'null' || tgId === 'undefined') return; byId[tgId] = { telegram_user_id: tgId, chat_id: String(parsed.chat_id || tgId), first_name: parsed.first_name || null, last_name: parsed.last_name || null, username: parsed.username || null, }; }; // Prefer chat_ids from parent when provided (no extra KV reads) if (Array.isArray(body.recipients) && body.recipients.length) { for (const u of body.recipients) remember(u); } else if (Array.isArray(body.chat_ids) && body.chat_ids.length && !body.all) { for (const id of body.chat_ids) remember({ telegram_user_id: id, chat_id: id }); } else if (env.BOT_DATA) { try { let cursor; do { const page = await env.BOT_DATA.list({ prefix: 'botuser:' + botId + ':', limit: 200, cursor, }); for (const k of (page.keys || [])) { try { const raw = await env.BOT_DATA.get(k.name); if (raw) remember(JSON.parse(raw)); } catch (_) {} } cursor = page.list_complete ? null : page.cursor; } while (cursor); } catch (_) {} try { const rawMap = await env.BOT_DATA.get('botusers:' + botId); if (rawMap) { const parsed = JSON.parse(rawMap); if (Array.isArray(parsed)) parsed.forEach(remember); else if (parsed && typeof parsed === 'object') { if (parsed.telegram_user_id || parsed.id) remember(parsed); else Object.values(parsed).forEach(remember); } } } catch (_) {} } const recipients = Object.values(byId); if (!recipients.length) { return Response.json({ ok: true, sent: 0, failed: 0, total: 0, errors: ['No subscribers yet'] }); } const MAX = 40; const batch = recipients.slice(0, MAX); let sent = 0; let failed = 0; const errors = []; for (let i = 0; i < batch.length; i++) { const user = batch[i]; const chatId = String(user.chat_id || user.telegram_user_id); const personalized = personalizeBroadcast(text, user); const result = photo ? await tg(env, 'sendPhoto', { chat_id: chatId, photo, caption: personalized }) : await tg(env, 'sendMessage', { chat_id: chatId, text: personalized, disable_web_page_preview: true }); if (result && result.ok) sent++; else { failed++; if (errors.length < 8) { errors.push(String(chatId) + ': ' + (result?.description || result?.error || 'send failed')); } } } await log('INFO', 'Broadcast relay: ' + sent + ' sent, ' + failed + ' failed', env); return Response.json({ ok: true, sent, failed, errors, total: batch.length, truncated: recipients.length > MAX, }); } catch (err) { return Response.json({ ok: false, error: String(err.message || err) }, { status: 500 }); } } return new Response('Telegram bot is running', { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); }, }; `; } // ============================================================ // DEPLOYMENT ROUTE — Now uses CFClient to deploy Workers // ============================================================ app.post('/api/bots/:id/deploy', authMiddleware, async (c) => { try { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const bots = await cf.d1Query('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); const bot = bots[0]; const botRules = await cf.d1Query('SELECT * FROM rules WHERE bot_id = ? ORDER BY priority ASC, id ASC', [botId]); if (botRules.length === 0) return c.json({ error: 'Add at least one rule before deploying' }, 400); const scriptName = `tb-${(bot.bot_username || String(botId)).toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')}`; const code = generateBotScript(botRules); if (!code.includes("build: 'submissions-inbox'") || !code.includes('Submission saved')) { return c.json({ error: 'Generated bot script is missing Submissions support. Paste the latest parent worker.js, deploy the parent, then try again.', }, 500); } const host2 = new URL(c.req.url || c.request.url); const parentUrl = host2.origin; // Critical: persist PLATFORM_CONFIG so child → parent D1 ingest works await ensurePlatformConfigPersisted(c, cf); if (!cf.kvId) { return c.json({ error: 'KV namespace missing. Re-run setup so logs/users can be stored.' }, 400); } const result = await cf.deployWorker(scriptName, code, { envVars: { BOT_TOKEN: bot.bot_token, PARENT_URL: parentUrl, BOT_ID: String(botId), LOG_LEVEL: 'DEBUG', LOG_ENABLED: 'true', }, kvId: cf.kvId, }); const workerUrl = result.workerUrl; await cf.d1Execute( 'UPDATE bots SET worker_url = ?, worker_script = ?, is_active = 1 WHERE id = ?', [workerUrl, result.scriptName || scriptName, botId] ); // Do NOT health-check here — deploy already uses many CF API subrequests (free plan = 50). // The dashboard verifies /health from the browser after deploy. const warning = result.readinessWarning ? ` ⚠️ ${result.readinessWarning}` : ''; return c.json({ message: `✅ Bot deployed! @${bot.bot_username} is live.${warning}`, workerUrl, scriptName: result.scriptName || scriptName, webhookUrl: `${workerUrl}/webhook`, webhook: result.webhook?.result || null, readinessWarning: result.readinessWarning || null, verifyHealthUrl: `${String(workerUrl).replace(/\/$/, '')}/health`, expectBuild: 'submissions-inbox', }); } catch (e) { return c.json({ error: 'Deployment failed: ' + e.message }, 500); } }); // ============================================================ // TOGGLE BOT ACTIVE/INACTIVE // ============================================================ app.post('/api/bots/:id/toggle', authMiddleware, async (c) => { try { const cf = getCFClient(c); if (!cf) return c.json({ error: 'Not initialized' }, 400); const userId = c.get('userId'); const botId = parseInt(c.req.param('id')); const bots = await cf.d1Query('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); if (bots.length === 0) return c.json({ error: 'Bot not found' }, 404); const bot = bots[0]; const newState = bot.is_active ? 0 : 1; // If activating, require the bot to have been deployed first if (newState === 1 && !bot.worker_url) { return c.json({ error: 'Deploy the bot first before activating it.' }, 400); } // If activating: set webhook on Telegram if (newState === 1 && bot.worker_url && bot.bot_token) { const hook = await fetch(`https://api.telegram.org/bot${bot.bot_token}/setWebhook`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: `${bot.worker_url}/webhook`, drop_pending_updates: false, allowed_updates: ['message', 'callback_query'], }), }); const hookData = await hook.json(); if (!hookData.ok) { return c.json({ error: 'Failed to activate bot: ' + (hookData.description || 'Telegram API error') }, 500); } } // If deactivating: remove webhook from Telegram so bot stops receiving updates if (newState === 0 && bot.bot_token) { await fetch(`https://api.telegram.org/bot${bot.bot_token}/deleteWebhook`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); } await cf.d1Execute('UPDATE bots SET is_active = ? WHERE id = ?', [newState, botId]); const updatedBots = await cf.d1Query('SELECT * FROM bots WHERE id = ? AND user_id = ?', [botId, userId]); return c.json({ success: true, is_active: newState, bot: updatedBots[0], message: newState === 1 ? `✅ @${bot.bot_username || bot.bot_name} is now active` : `⏸️ @${bot.bot_username || bot.bot_name} is now paused`, }); } catch (e) { return c.json({ error: 'Toggle failed: ' + e.message }, 500); } }); // ============================================================ // DASHBOARD UI // ============================================================ app.get('/', (c) => { const savedPlatformConfig = parsePlatformConfig(c.env?.PLATFORM_CONFIG); return html`
One-time initialization to connect your Cloudflare account
Build Telegram bots without coding