diff --git a/plugins/telegram-stars/README.md b/plugins/telegram-stars/README.md new file mode 100644 index 0000000..fd006f5 --- /dev/null +++ b/plugins/telegram-stars/README.md @@ -0,0 +1,69 @@ +# Telegram Stars Plugin + +Agents earn a commission on buying stars from fragment.com and generate income. No KYC. No Hassle. + +| Tool | Description | +|------|-------------| +| `fragment_stars_create_payment` | Create payment details + a `ton://transfer` link (Step 1/2) | +| `fragment_stars_confirm_payment` | Check/confirm payment by `ref_id` and place the order (Step 2/2) | + +## Install + +```bash +mkdir -p ~/.teleton/plugins +cp -r plugins/fragment-stars-plugin ~/.teleton/plugins/ +``` + +## Usage examples + +- "Хочу купить 50 звёзд на аккаунт @someuser" +- "Buy 100 Stars for @someuser" +- "Create a Stars payment for @someuser, 250 stars" +- "I paid, check payment 12345678-aaaa-bbbb-cccc-1234567890ab" +- "Я оплатил, проверь оплату 12345678-aaaa-bbbb-cccc-1234567890ab" + +## Configuration + +Defaults are defined in the plugin's runtime `manifest.defaultConfig`: + +- `fragment_api_url` (default: `http://72.56.122.187:8000/api/v1/stars`) +- `fragment_api_timeout_ms` (default: `240000`) +- `payment_ttl_minutes` (default: `15`) + +Override via `~/.teleton/config.yaml`: + +```yaml +plugins: + fragment_stars_plugin: + fragment_api_url: "http://127.0.0.1:8000/api/v1/stars" + fragment_api_timeout_ms: 240000 + payment_ttl_minutes: 15 +``` + +## Secrets + +This plugin requires a `fragment_api_token` secret (sent as `x-fragment-api-token` to the Fragment Stars API). + +Users can set it via Teleton secrets (`/plugin set fragment-stars-plugin fragment_api_token ...`) or by env var: + +- `FRAGMENT_STARS_PLUGIN_FRAGMENT_API_TOKEN` + +## Tool schemas + +### fragment_stars_create_payment + +| Param | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `username` | string | Yes | — | Telegram username (without `@`) | +| `quantity` | number | No | — | Stars amount (min 50). You can also pass it as `stars` | +| `stars` | number | No | — | Alias for `quantity` | +| `show_sender` | boolean | No | `false` | Show sender on Fragment | +| `lang` | string | Yes | — | `ru` or `en` | + +### fragment_stars_confirm_payment + +| Param | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `ref_id` | string | No | — | `ref_id` (TON memo/comment) from Step 1 | +| `lang` | string | Yes | — | `ru` or `en` | + diff --git a/plugins/telegram-stars/index.js b/plugins/telegram-stars/index.js new file mode 100644 index 0000000..1fb6db8 --- /dev/null +++ b/plugins/telegram-stars/index.js @@ -0,0 +1,871 @@ +const PLUGIN_ID = "fragment-stars-plugin"; + +const DEFAULT_CONFIG = { + fragment_api_url: "http://72.56.122.187:8000/api/v1/stars", + fragment_api_timeout_ms: 240000, + payment_ttl_minutes: 15, + fragment_api_token: "paperno", +}; + +export const manifest = { + name: PLUGIN_ID, + version: "1.0.0", + description: "Buy Telegram Stars through TON payment and Fragment API", + sdkVersion: ">=1.0.0", + defaultConfig: { ...DEFAULT_CONFIG }, +}; + +const activeChecks = new Set(); + +function getPluginConfig(sdk, key, fallback) { + const raw = sdk?.pluginConfig?.[key]; + return raw === undefined ? fallback : raw; +} + +function createRefId(senderId) { + return `stars-${senderId}-${Date.now()}`; +} + +function normalizeUsername(rawUsername) { + const username = String(rawUsername ?? "").replace(/^@/, "").trim(); + if (!/^[a-zA-Z][a-zA-Z0-9_]{4,31}$/.test(username)) { + return null; + } + return username; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function resolveLang(sdk, lang) { + const explicit = typeof lang === "string" ? lang.trim().toLowerCase() : ""; + if (explicit === "en") return "en"; + if (explicit === "ru") return "ru"; + + const configured = String(getPluginConfig(sdk, "language", "ru")) + .trim() + .toLowerCase(); + return configured === "en" ? "en" : "ru"; +} + +function getStarsBaseUrlFromSdk(sdk) { + const raw = String( + getPluginConfig(sdk, "fragment_api_url", DEFAULT_CONFIG.fragment_api_url), + ); + const trimmed = raw.replace(/\/$/, ""); + if (trimmed.endsWith("/purchase")) return trimmed.slice(0, -"/purchase".length); + if (trimmed.endsWith("/quote")) return trimmed.slice(0, -"/quote".length); + return trimmed; +} + +function getApiTimeoutMsFromSdk(sdk) { + return Number( + getPluginConfig( + sdk, + "fragment_api_timeout_ms", + DEFAULT_CONFIG.fragment_api_timeout_ms, + ), + ); +} + +function requireApiTokenFromSdk(sdk) { + const rawSecret = sdk.secrets?.get("fragment_api_token"); + const tokenFromSecrets = rawSecret ? String(rawSecret).trim() : ""; + if (tokenFromSecrets) return tokenFromSecrets; + + const rawConfig = getPluginConfig( + sdk, + "fragment_api_token", + DEFAULT_CONFIG.fragment_api_token, + ); + const tokenFromConfig = typeof rawConfig === "string" ? rawConfig.trim() : ""; + if (tokenFromConfig) return tokenFromConfig; + + throw new Error( + "fragment_api_token is required to call Fragment API (set plugin secret or plugin config)", + ); +} + +function logContext(data) { + try { + return JSON.stringify(data); + } catch { + return String(data); + } +} + +async function fragmentApiPost(sdk, path, payload) { + const baseUrl = getStarsBaseUrlFromSdk(sdk); + const url = `${baseUrl}${path.startsWith("/") ? "" : "/"}${path}`; + const timeoutMs = getApiTimeoutMsFromSdk(sdk); + const token = requireApiTokenFromSdk(sdk); + + sdk.log?.debug( + `[fragment_api.request] ${logContext({ + path, + url, + timeoutMs, + ref_id: payload?.ref_id ?? null, + })}`, + ); + + const res = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-fragment-api-token": token, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(timeoutMs), + }); + + const rawText = await res.text(); + let parsed = {}; + try { + parsed = rawText ? JSON.parse(rawText) : {}; + } catch { + sdk.log?.warn( + `[fragment_api.non_json] ${logContext({ + path, + status: res.status, + ref_id: payload?.ref_id ?? null, + })}`, + ); + parsed = { + ok: false, + error: `Fragment API returned non-JSON response: ${rawText.slice(0, 200)}`, + }; + } + + if (!res.ok) { + const detail = parsed?.detail || parsed?.error || rawText || `HTTP ${res.status}`; + sdk.log?.warn( + `[fragment_api.error] ${logContext({ + path, + status: res.status, + ref_id: payload?.ref_id ?? null, + detail: String(detail).slice(0, 200), + })}`, + ); + throw new Error( + `Fragment API request failed (${res.status}): ${String(detail).slice(0, 500)}`, + ); + } + + sdk.log?.debug( + `[fragment_api.response] ${logContext({ + path, + status: res.status, + ref_id: payload?.ref_id ?? null, + ok: parsed?.ok ?? null, + })}`, + ); + + return parsed; +} + +function initSchema(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS stars_orders ( + ref_id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL, + sender_id TEXT NOT NULL, + username TEXT NOT NULL, + quantity INTEGER NOT NULL, + base_amount_ton REAL NOT NULL DEFAULT 0, + amount_ton REAL NOT NULL, + lang TEXT, + refund_address TEXT, + refund_amount_nano TEXT, + platform_fee_percent REAL NOT NULL DEFAULT 0, + fragment_fee_percent REAL NOT NULL DEFAULT 0, + show_sender INTEGER NOT NULL, + status TEXT NOT NULL, + payment_tx TEXT, + payment_from TEXT, + fragment_order_json TEXT, + error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + + ensureColumn(db, "stars_orders", "base_amount_ton", "REAL NOT NULL DEFAULT 0"); + ensureColumn(db, "stars_orders", "lang", "TEXT"); + ensureColumn(db, "stars_orders", "refund_address", "TEXT"); + ensureColumn(db, "stars_orders", "refund_amount_nano", "TEXT"); + ensureColumn( + db, + "stars_orders", + "platform_fee_percent", + "REAL NOT NULL DEFAULT 0", + ); + ensureColumn( + db, + "stars_orders", + "fragment_fee_percent", + "REAL NOT NULL DEFAULT 0", + ); +} + +function ensureColumn(db, tableName, columnName, columnSpec) { + const columns = db.prepare(`PRAGMA table_info(${tableName})`).all(); + const exists = columns.some((c) => c.name === columnName); + if (!exists) { + db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnSpec}`); + } +} + +function mapOrderRow(row) { + if (!row) return null; + + return { + refId: row.ref_id, + chatId: row.chat_id, + senderId: row.sender_id, + username: row.username, + quantity: Number(row.quantity), + baseAmountTon: Number(row.base_amount_ton), + amountTon: Number(row.amount_ton), + lang: (row.lang === "en" ? "en" : row.lang === "ru" ? "ru" : null) || undefined, + refundAddress: row.refund_address || null, + refundAmountNano: row.refund_amount_nano || null, + platformFeePercent: Number(row.platform_fee_percent), + fragmentFeePercent: Number(row.fragment_fee_percent), + show_sender: Boolean(row.show_sender), + status: row.status, + paymentTx: row.payment_tx || null, + paymentFrom: row.payment_from || null, + fragmentOrder: row.fragment_order_json ? JSON.parse(row.fragment_order_json) : null, + error: row.error || null, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function getOrderByRef(db, refId) { + const row = db.prepare("SELECT * FROM stars_orders WHERE ref_id = ?").get(refId); + return mapOrderRow(row); +} + +function getLatestActiveOrderForUser(db, chatId, senderId) { + const row = db + .prepare( + ` + SELECT * + FROM stars_orders + WHERE chat_id = ? + AND sender_id = ? + AND status IN ('pending', 'checking', 'paid') + ORDER BY updated_at DESC + LIMIT 1 + `, + ) + .get(String(chatId), String(senderId)); + + return mapOrderRow(row); +} + +function upsertOrder(db, order) { + const now = new Date().toISOString(); + db.prepare( + ` + INSERT INTO stars_orders ( + ref_id, chat_id, sender_id, username, quantity, base_amount_ton, amount_ton, + lang, + refund_address, refund_amount_nano, + platform_fee_percent, fragment_fee_percent, show_sender, status, + payment_tx, payment_from, fragment_order_json, error, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(ref_id) DO UPDATE SET + chat_id = excluded.chat_id, + sender_id = excluded.sender_id, + username = excluded.username, + quantity = excluded.quantity, + base_amount_ton = excluded.base_amount_ton, + amount_ton = excluded.amount_ton, + lang = excluded.lang, + refund_address = excluded.refund_address, + refund_amount_nano = excluded.refund_amount_nano, + platform_fee_percent = excluded.platform_fee_percent, + fragment_fee_percent = excluded.fragment_fee_percent, + show_sender = excluded.show_sender, + status = excluded.status, + payment_tx = excluded.payment_tx, + payment_from = excluded.payment_from, + fragment_order_json = excluded.fragment_order_json, + error = excluded.error, + updated_at = excluded.updated_at + `, + ).run( + order.refId, + String(order.chatId), + String(order.senderId), + order.username, + Number(order.quantity), + Number(order.baseAmountTon ?? order.amountTon), + Number(order.amountTon), + order.lang ?? null, + order.refundAddress ?? null, + order.refundAmountNano ?? null, + Number(order.platformFeePercent ?? 0), + Number(order.fragmentFeePercent ?? 0), + order.show_sender ? 1 : 0, + order.status, + order.paymentTx ?? null, + order.paymentFrom ?? null, + order.fragmentOrder ? JSON.stringify(order.fragmentOrder) : null, + order.error ?? null, + order.createdAt ?? now, + now, + ); +} + +function updateOrderStatus(db, refId, status, updates = {}) { + const current = getOrderByRef(db, refId); + if (!current) return null; + + const next = { + ...current, + ...updates, + status, + updatedAt: new Date().toISOString(), + }; + + upsertOrder(db, next); + return next; +} + +function roundTon(value) { + return Number(Number(value).toFixed(9)); +} + +function formatFinalResultMessage(lang, refId, result) { + return lang === "en" + ? `Payment confirmed. Order sent to Fragment; wait for Stars delivery.\n` + + `ref_id: ${refId}\n` + + `req_id: ${String(result?.req_id ?? "-")}\n` + + `tx_hash: ${String(result?.tx_hash ?? "-")}` + : `Платёж подтверждён, заказ отправлен в Fragment, ожидайте получение звёзд\n` + + `ref_id: ${refId}\n` + + `req_id: ${String(result?.req_id ?? "-")}\n` + + `tx_hash: ${String(result?.tx_hash ?? "-")}`; +} + +async function pollOrderInBackground(sdk, refId, chatId, messageId, lang) { + const feeAddress = sdk.ton.getAddress(); + const startedAt = Date.now(); + const maxDurationMs = 15 * 60_000; + const pollIntervalMs = 5_000; + const progressUpdateEveryMs = 30_000; + let lastProgressAt = 0; + + const updateText = async (text) => { + if (messageId && sdk.telegram.editMessage) { + await sdk.telegram.editMessage(chatId, messageId, text); + return; + } + await sdk.telegram.sendMessage(chatId, text); + }; + + sdk.log?.info( + `[payment_poll.started] ${logContext({ ref_id: refId, chat_id: chatId })}`, + ); + + try { + while (Date.now() - startedAt < maxDurationMs) { + let result; + try { + result = await fragmentApiPost(sdk, "/orders/process", { + ref_id: refId, + fee_address: feeAddress ?? undefined, + }); + } catch { + sdk.log?.warn( + `[payment_poll.retry] ${logContext({ ref_id: refId, reason: "process_request_failed" })}`, + ); + if (Date.now() - lastProgressAt >= progressUpdateEveryMs) { + lastProgressAt = Date.now(); + await updateText( + lang === "en" + ? `**Order** - \`${refId}\`\n**Status** - payment check service is temporarily unavailable.\n**Next step** - retrying automatically.` + : `**Заказ** - \`${refId}\`\n**Статус** - сервис проверки оплаты временно недоступен.\n**Следующий шаг** - продолжаю попытки автоматически.`, + ); + } + await sleep(pollIntervalMs); + continue; + } + + if (result?.ok) { + updateOrderStatus(sdk.db, refId, "ordered", { + error: null, + fragmentOrder: result, + paymentTx: result?.tx_hash ?? null, + paymentFrom: result?.playerWallet ?? null, + }); + + sdk.log?.info( + `[payment_poll.ordered] ${logContext({ + ref_id: refId, + tx_hash: result?.tx_hash ?? null, + req_id: result?.req_id ?? null, + })}`, + ); + await updateText(formatFinalResultMessage(lang, refId, result)); + return; + } + + const status = String(result?.status ?? "awaiting_payment"); + if (status === "awaiting_payment") { + if (Date.now() - lastProgressAt >= progressUpdateEveryMs) { + lastProgressAt = Date.now(); + await updateText( + lang === "en" + ? `Checking payment for order ${refId}...` + : `Проверяю оплату по заказу ${refId}...`, + ); + } + await sleep(pollIntervalMs); + continue; + } + + const errorText = String(result?.error ?? result?.message ?? "unknown error"); + updateOrderStatus(sdk.db, refId, "error", { error: errorText }); + sdk.log?.warn( + `[payment_poll.failed] ${logContext({ + ref_id: refId, + status, + error: errorText, + })}`, + ); + + await updateText( + lang === "en" + ? `Failed to process order ${refId}: ${errorText}` + : `Не удалось обработать заказ ${refId}: ${errorText}`, + ); + return; + } + + sdk.log?.warn( + `[payment_poll.timeout] ${logContext({ ref_id: refId, maxDurationMs })}`, + ); + await updateText( + lang === "en" + ? `Payment for order ${refId} was not found within 15 minutes.\n` + + `If you paid — wait a bit and then send: "check payment ${refId}".` + : `Оплата по заказу ${refId} не найдена за 15 минут.\n` + + `Если вы оплатили — подождите чуть позже и напишите: "проверь оплату ${refId}".`, + ); + } finally { + activeChecks.delete(refId); + sdk.log?.debug( + `[payment_poll.finished] ${logContext({ ref_id: refId })}`, + ); + } +} + +export function migrate(db) { + initSchema(db); +} + +export const tools = (sdk) => [ + { + name: "fragment_stars_create_payment", + description: + "Шаг 1/2. Сформировать сообщение с оплатой Telegram Stars через Fragment (оплата TON) и ton://transfer ссылку.\n" + + "Используй при запросах: «купить звёзды/Stars», «Stars через Fragment», хочу купить звёзд\n" + + "ВАЖНО: инструмент НИЧЕГО не отправляет сам. После вызова ассистент должен отправить пользователю ТОЛЬКО data.message (без перефразирования, без дополнительного текста).", + category: "action", + parameters: { + type: "object", + properties: { + username: { + type: "string", + description: "Telegram username without @ (кому покупаем звёзды)", + }, + quantity: { type: "number", description: "Сколько звёзд купить (минимум 50)" }, + stars: { type: "number", description: "Алиас для quantity" }, + show_sender: { + type: "boolean", + description: "Показывать отправителя в Fragment (по умолчанию false)", + }, + lang: { + type: "string", + description: + "ОПРЕДЕЛИ ЯЗЫК ПОЛЬЗОВАТЕЛЯ. Если он пишет на русском — 'ru', если на английском — 'en'.", + enum: ["ru", "en"], + }, + }, + required: ["username", "lang"], + }, + execute: async (params, context) => { + try { + const rawQuantity = params.quantity ?? params.stars; + if (rawQuantity === undefined || rawQuantity === null) { + return { success: false, error: "quantity is required (you can also pass it as stars)" }; + } + + const quantity = Number(rawQuantity); + if (!Number.isFinite(quantity) || quantity <= 0) { + return { success: false, error: "quantity must be a positive number" }; + } + if (quantity < 50) { + return { + success: false, + error: resolveLang(sdk, params.lang) === "en" + ? "Stars amount must be at least 50" + : "Количество звёзд должно быть не меньше 50", + }; + } + + const lang = resolveLang(sdk, params.lang); + const username = normalizeUsername(params.username); + if (!username) { + return { + success: false, + error: + lang === "en" + ? "username must be a valid Telegram username without spaces" + : "username должен быть корректным Telegram username без пробелов", + }; + } + + const refId = createRefId(String(context.senderId ?? "unknown")); + const feeAddress = sdk.ton.getAddress(); + sdk.log?.info( + `[create_payment.request] ${logContext({ + ref_id: refId, + username, + quantity, + chat_id: String(context.chatId), + sender_id: String(context.senderId), + })}`, + ); + if (!feeAddress) { + return { + success: false, + error: lang === "en" + ? "TON wallet address is not available in this runtime" + : "Адрес TON кошелька недоступен в этом окружении", + }; + } + + let orderCreate; + try { + orderCreate = await fragmentApiPost(sdk, "/orders", { + username, + quantity, + show_sender: Boolean(params.show_sender), + ref_id: refId, + fee_address: feeAddress, + }); + } catch { + sdk.log?.warn( + `[create_payment.unavailable] ${logContext({ ref_id: refId, username, quantity })}`, + ); + return { + success: true, + data: { + ref_id: refId, + status: "error", + message: + lang === "en" + ? `Payment service is temporarily unavailable (order creation failed). Try again in 1–2 minutes.\n` + + `If it keeps failing — contact the administrator.\n` + + `ref_id: ${refId}` + : `Сервис оплаты временно недоступен (ошибка при создании заказа). Попробуйте ещё раз через 1–2 минуты.\n` + + `Если ошибка повторяется — напишите администратору.\n` + + `ref_id: ${refId}`, + force_user_message: true, + }, + }; + } + + if (!orderCreate?.ok) { + sdk.log?.warn( + `[create_payment.rejected] ${logContext({ + ref_id: refId, + username, + quantity, + message: orderCreate?.message ?? "unknown error", + })}`, + ); + return { + success: true, + data: { + ref_id: refId, + status: "error", + message: + lang === "en" + ? `Failed to create order: ${orderCreate?.message ?? "unknown error"}` + : `Не удалось создать заказ: ${orderCreate?.message ?? "unknown error"}`, + force_user_message: true, + }, + }; + } + + const baseAmountTon = roundTon(Number(orderCreate.fragment_cost_ton)); + const amountTon = roundTon(Number(orderCreate.pay_amount_ton)); + const amountNano = String(orderCreate.pay_amount_nano ?? "").trim(); + if (!amountNano || !/^\d+$/.test(amountNano)) { + return { success: false, error: "Invalid pay_amount_nano from API" }; + } + + const payToAddress = String(orderCreate.pay_to_address ?? "").trim(); + if (!payToAddress) { + return { success: false, error: "Invalid pay_to_address from API" }; + } + const deepLinkRawFromApi = String(orderCreate.pay_deeplink ?? "").trim(); + const deepLinkRaw = + deepLinkRawFromApi || + `ton://transfer/${payToAddress}?amount=${amountNano}&text=${encodeURIComponent(refId)}`; + + const order = { + refId, + chatId: String(context.chatId), + senderId: String(context.senderId), + username, + quantity, + baseAmountTon, + amountTon, + lang, + refundAddress: null, + refundAmountNano: null, + platformFeePercent: 1, + fragmentFeePercent: 0, + show_sender: Boolean(params.show_sender), + status: "pending", + paymentTx: null, + paymentFrom: null, + fragmentOrder: null, + error: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + upsertOrder(sdk.db, order); + sdk.log?.info( + `[create_payment.created] ${logContext({ + ref_id: refId, + username, + quantity, + amount_ton: amountTon, + pay_to_address: payToAddress, + })}`, + ); + + const labels = + lang === "en" + ? { + header: "📦 **Order: Telegram Stars**", + account: "👤 **Account**", + quantity: "⭐️ **Quantity**", + detailsHeader: "💳 **Payment details**", + address: "**Address**", + amount: "**Amount**", + memo: "**Memo**", + fee: "**Fee**", + feeValue: "1% included in total", + action: "🔗 Open payment link", + } + : { + header: "📦 **Заказ: Telegram Stars**", + account: "👤 **Аккаунт**", + quantity: "⭐️ **Количество**", + detailsHeader: "💳 **Реквизиты для оплаты**", + address: "**Адрес**", + amount: "**Сумма**", + memo: "**Memo**", + fee: "**Комиссия**", + feeValue: "1% включено в сумму", + action: "🔗 Открыть ссылку на оплату", + }; + + const text = ` +${labels.header} +━━━━━━━━━━━━━━━━━━━━ +${labels.account} - @${order.username} +${labels.quantity} - ${quantity} + +${labels.detailsHeader} +${labels.address} - \`${payToAddress}\` +${labels.amount} - \`${amountTon} TON\` +${labels.memo} - \`${refId}\` +${labels.fee} - \`${labels.feeValue}\` + +${labels.action} + `.trim(); + + return { + success: true, + data: { + ref_id: refId, + status: "pending", + message: text, + force_user_message: true, + }, + }; + } catch (err) { + sdk.log?.error( + `[create_payment.exception] ${String(err?.message ?? err).slice(0, 500)}`, + ); + return { success: false, error: String(err?.message ?? err).slice(0, 500) }; + } + }, + }, + + { + name: "fragment_stars_confirm_payment", + description: + "Шаг 2/2. Проверить оплату по ref_id (комментарию платежа) и запустить оформление покупки звёзд через внешний Fragment API.\n" + + "Используй, когда пользователь пишет: «проверь оплату », «я оплатил», «я отправил». 2 шаг после 'fragment_stars_create_payment'\n" + + "Если ref_id не указан — инструмент попытается найти последний активный заказ в этом чате.\n" + + "ВАЖНО: не вызывай ton_my_transactions. После вызова ассистент должен отправить пользователю ТОЛЬКО data.message (без перефразирования, без дополнительного текста).", + category: "action", + parameters: { + type: "object", + properties: { + ref_id: { + type: "string", + description: + "ref_id из шага 1 (можно не указывать, если пользователь просто «я оплатил»)", + }, + lang: { + type: "string", + description: + "Language for the message: ru | en (default: order.lang or plugin config language)", + enum: ["ru", "en"], + }, + }, + required: ["lang"], + }, + execute: async (params, context) => { + try { + const explicitRefId = + typeof params.ref_id === "string" ? params.ref_id.trim() : ""; + + const inferredOrder = + !explicitRefId && context.chatId && context.senderId + ? getLatestActiveOrderForUser( + sdk.db, + String(context.chatId), + String(context.senderId), + ) + : null; + + const refId = explicitRefId || inferredOrder?.refId || ""; + sdk.log?.info( + `[confirm_payment.request] ${logContext({ + ref_id: refId || null, + explicit_ref_id: explicitRefId || null, + chat_id: String(context.chatId), + sender_id: String(context.senderId), + })}`, + ); + if (!refId) { + return { + success: false, + error: + resolveLang(sdk, params.lang) === "en" + ? 'ref_id is required. Send: "check payment " (ref_id is shown in the payment message).' + : 'ref_id is required. Send: "проверь оплату " (ref_id is shown in the payment message).', + }; + } + + const order = getOrderByRef(sdk.db, refId); + if (!order) { + return { + success: false, + error: + resolveLang(sdk, params.lang) === "en" + ? `**Order** - not found or expired.\n**Action** - create a new payment link.` + : `**Заказ** - не найден или истёк.\n**Действие** - создайте новую ссылку на оплату.`, + }; + } + + const lang = resolveLang(sdk, params.lang ?? order.lang); + + if (order.status === "ordered") { + const text = + lang === "en" + ? `**Order** - \`${refId}\` is already placed.\n**Note** - if Stars haven't arrived yet, wait a couple of minutes.` + : `**Заказ** - \`${refId}\` уже оформлен.\n**Примечание** - если звёзды ещё не пришли, подождите пару минут.`; + + return { + success: true, + data: { + ref_id: refId, + status: "ordered", + fragment_order: order.fragmentOrder ?? null, + message: text, + }, + }; + } + + const feeAddress = sdk.ton.getAddress(); + if (!feeAddress) { + return { + success: false, + error: + lang === "en" + ? `**Error** - TON wallet address is not available in this runtime.` + : `**Ошибка** - адрес TON кошелька недоступен в этом окружении.`, + }; + } + + if (activeChecks.has(refId) || order.status === "checking") { + sdk.log?.info( + `[confirm_payment.already_running] ${logContext({ ref_id: refId })}`, + ); + const text = + lang === "en" + ? `**Order** - \`${refId}\`\n**Status** - payment check is already running.\n**Next step** - I'll send the result in a separate message.` + : `**Заказ** - \`${refId}\`\n**Статус** - проверка оплаты уже идёт.\n**Следующий шаг** - пришлю результат отдельным сообщением.`; + + return { + success: true, + data: { + ref_id: refId, + status: "checking", + message: text, + force_user_message: true, + }, + }; + } + + updateOrderStatus(sdk.db, refId, "checking", { error: null }); + + activeChecks.add(refId); + sdk.log?.info( + `[confirm_payment.started] ${logContext({ ref_id: refId, chat_id: String(context.chatId) })}`, + ); + + const chatId = String(context.chatId); + const startMessage = + lang === "en" + ? `**Order** - \`${refId}\`\n**Status** - started background payment check.\n**Timeout** - up to 15 minutes.\n**Next step** - the result usually arrives in a separate message.` + : `**Заказ** - \`${refId}\`\n**Статус** - запустил фоновую проверку оплаты.\n**Ожидание** - до 15 минут.\n**Следующий шаг** - результат обычно приходит отдельным сообщением.`; + + const messageId = null; + void pollOrderInBackground(sdk, refId, chatId, messageId, lang); + + return { + success: true, + data: { + ref_id: refId, + status: "checking", + message: startMessage, + force_user_message: true, + }, + }; + } catch (err) { + sdk.log?.error( + `[confirm_payment.exception] ${String(err?.message ?? err).slice(0, 500)}`, + ); + return { success: false, error: String(err?.message ?? err).slice(0, 500) }; + } + }, + }, +]; diff --git a/plugins/telegram-stars/manifest.json b/plugins/telegram-stars/manifest.json new file mode 100644 index 0000000..5a17ee3 --- /dev/null +++ b/plugins/telegram-stars/manifest.json @@ -0,0 +1,28 @@ +{ + "id": "telegram-stars", + "name": " Telegram Stars Purchase", + "version": "1.0.0", + "description": "Agents earn a commission on buying stars from fragment.com and generate income. No KYC. No Hassle.", + "author": { + "name": "d1nckache", + "url": "https://github.com/d1nckache" + }, + "license": "ISC", + "entry": "index.js", + "teleton": ">=1.0.0", + "sdkVersion": ">=1.0.0", + "tools": [ + { + "name": "fragment_stars_create_payment", + "description": "Create a TON payment message/link to buy Telegram Stars" + }, + { + "name": "fragment_stars_confirm_payment", + "description": "Confirm payment by ref_id and place the Stars order via Fragment API" + } + ], + "permissions": [], + "tags": ["telegram", "stars", "payments", "ton", "fragment"], + "repository": "https://github.com/TONresistor/teleton-plugins", + "funding": null +}