From 26b89f5ddc295e4be7c087fd420acdd383aaa546 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 29 Jun 2026 17:50:42 -0300 Subject: [PATCH 01/19] feat: add /scheduleorder and /cancelschedule commands for recurring orders Creates a new ScheduledOrder model that acts as a mold: each cron trigger publishes a brand-new Order with its own id. No order lifecycle is mutated. Taking a scheduled order resets the cycle counter and immediately republishes from the mold so the trader's offer stays live without manual intervention. --- bot/modules/orders/takeOrder.ts | 61 ++++++- bot/modules/schedule/commands.ts | 296 +++++++++++++++++++++++++++++++ bot/modules/schedule/index.ts | 25 +++ bot/modules/schedule/messages.ts | 82 +++++++++ bot/start.ts | 8 + jobs/index.ts | 2 + jobs/scheduled_orders.ts | 89 ++++++++++ locales/en.yaml | 27 +++ models/index.ts | 12 +- models/scheduled_order.ts | 45 +++++ tests/bot/bot.spec.ts | 2 +- 11 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 bot/modules/schedule/commands.ts create mode 100644 bot/modules/schedule/index.ts create mode 100644 bot/modules/schedule/messages.ts create mode 100644 jobs/scheduled_orders.ts create mode 100644 models/scheduled_order.ts diff --git a/bot/modules/orders/takeOrder.ts b/bot/modules/orders/takeOrder.ts index d705deba..b4b4a0f7 100644 --- a/bot/modules/orders/takeOrder.ts +++ b/bot/modules/orders/takeOrder.ts @@ -1,9 +1,10 @@ import { logger } from '../../../logger'; -import { Block, Order, User } from '../../../models'; +import { Block, Order, User, ScheduledOrder } from '../../../models'; import { UserDocument } from '../../../models/user'; import { deleteOrderFromChannel, generateRandomImage, + getUserI18nContext, PerOrderIdMutex, } from '../../../util'; import * as messages from '../../messages'; @@ -93,6 +94,7 @@ export const takebuy = async ( OrderEvents.orderUpdated(order); await deleteOrderFromChannel(order, bot.telegram); + await refreshScheduledOrder(order._id, bot); await messages.beginTakeBuyMessage(ctx, bot, user, order); }); @@ -145,6 +147,7 @@ export const takesell = async ( OrderEvents.orderUpdated(order); // We delete the messages related to that order from the channel await deleteOrderFromChannel(order, bot.telegram); + await refreshScheduledOrder(order._id, bot); await messages.beginTakeSellMessage(ctx, bot, user, order); }); } catch (error) { @@ -152,6 +155,62 @@ export const takesell = async ( } }; +// When a scheduled order is taken, reset its cycle counter and publish a +// fresh order from the mold so the trader's offer stays live on the channel. +const refreshScheduledOrder = async ( + orderId: string, + bot: HasTelegram, +): Promise => { + try { + const REPUBLISH_DAYS_DEFAULT = 10; + const raw = parseInt(process.env.REPUBLISH_ORDER_DAYS || ''); + const republishCount = + Number.isInteger(raw) && raw > 0 ? raw : REPUBLISH_DAYS_DEFAULT; + + const schedule = await ScheduledOrder.findOne({ + last_order_id: orderId, + active: true, + }); + if (!schedule) return; + + const creator = await User.findById(schedule.creator_id); + if (!creator) return; + + const i18n = await getUserI18nContext(creator); + + const ordersActions = require('../../ordersActions'); + const { + publishBuyOrderMessage, + publishSellOrderMessage, + } = require('../../messages'); + + const newOrder = await ordersActions.createOrder(i18n, bot, creator, { + type: schedule.type, + amount: schedule.amount, + fiatAmount: schedule.fiat_amount, + fiatCode: schedule.fiat_code, + paymentMethod: schedule.payment_method, + status: 'PENDING', + priceMargin: schedule.price_margin, + community_id: schedule.community_id, + }); + + if (!newOrder) return; + + const publishFn = + schedule.type === 'buy' + ? publishBuyOrderMessage + : publishSellOrderMessage; + await publishFn(bot, creator, newOrder, i18n, false); + + schedule.last_order_id = newOrder._id; + schedule.republish_count = republishCount; + await schedule.save(); + } catch (error) { + logger.error(`refreshScheduledOrder error: ${String(error)}`); + } +}; + const checkBlockingStatus = async ( ctx: MainContext, user: UserDocument, diff --git a/bot/modules/schedule/commands.ts b/bot/modules/schedule/commands.ts new file mode 100644 index 00000000..44d3a213 --- /dev/null +++ b/bot/modules/schedule/commands.ts @@ -0,0 +1,296 @@ +import { Telegraf } from 'telegraf'; +import { MainContext } from '../../start'; +import { ScheduledOrder } from '../../../models'; +import { validateSellOrder, validateBuyOrder } from '../../validations'; +import { getCurrency } from '../../../util'; +import { logger } from '../../../logger'; +import * as messages from './messages'; + +const REPUBLISH_DAYS_DEFAULT = 10; + +const getRepublishCount = () => { + const raw = parseInt(process.env.REPUBLISH_ORDER_DAYS || ''); + return Number.isInteger(raw) && raw > 0 ? raw : REPUBLISH_DAYS_DEFAULT; +}; + +// Day names (Spanish, English, abbreviated) mapped to getUTCDay() values (0=Sun) +const DAY_ALIASES: Record = { + domingo: 0, + sunday: 0, + sun: 0, + dom: 0, + lunes: 1, + monday: 1, + mon: 1, + lun: 1, + martes: 2, + tuesday: 2, + tue: 2, + mar: 2, + miercoles: 3, + miércoles: 3, + wednesday: 3, + wed: 3, + mie: 3, + mié: 3, + jueves: 4, + thursday: 4, + thu: 4, + jue: 4, + viernes: 5, + friday: 5, + fri: 5, + vie: 5, + sabado: 6, + sábado: 6, + saturday: 6, + sat: 6, + sab: 6, + sáb: 6, +}; + +export const parseCustomDays = (input: string): number[] | null => { + const parts = input + .toLowerCase() + .split(/[,\s]+/) + .filter(Boolean); + if (parts.length === 0) return null; + const days = new Set(); + for (const part of parts) { + const normalized = part.normalize('NFD').replace(/[̀-ͯ]/g, ''); + const dayNum = + DAY_ALIASES[part] ?? + DAY_ALIASES[normalized] ?? + DAY_ALIASES[ + part.replace( + /[áéíóú]/g, + c => ({ á: 'a', é: 'e', í: 'i', ó: 'o', ú: 'u' })[c] || c, + ) + ]; + if (dayNum === undefined) return null; + days.add(dayNum); + } + return [...days].sort(); +}; + +// Presets +const PRESET_DAYS: Record = { + daily: [0, 1, 2, 3, 4, 5, 6], + weekdays: [1, 2, 3, 4, 5], + weekend: [0, 6], +}; + +export const scheduleorder = async (ctx: MainContext) => { + try { + const user = ctx.user; + const text = (ctx.message as any)?.text || ''; + const [, type, ...rest] = text.trim().split(/\s+/); + + if (!type || !['buy', 'sell'].includes(type.toLowerCase())) { + return ctx.reply(ctx.i18n.t('scheduleorder_usage')); + } + + const orderType = type.toLowerCase(); + + // Reuse existing validators by temporarily rewriting ctx.state.command.args + const originalArgs = ctx.state?.command?.args; + if (!ctx.state) (ctx as any).state = {}; + if (!ctx.state.command) (ctx as any).state.command = {}; + ctx.state.command.args = rest; + + const params = + orderType === 'sell' + ? await validateSellOrder(ctx) + : await validateBuyOrder(ctx); + + ctx.state.command.args = originalArgs; + + if (!params) return; + + if (!getCurrency(params.fiatCode)) { + await ctx.reply(ctx.i18n.t('must_be_valid_currency')); + return; + } + + // Store pending schedule params in session-like state via a temporary + // message-level store keyed by user tg_id (in-memory, ephemeral) + pendingSchedules.set(user.tg_id, { + type: orderType, + ...params, + }); + + await messages.askScheduleDays(ctx); + } catch (error) { + logger.error(error); + } +}; + +export const cancelschedule = async (ctx: MainContext) => { + try { + const user = ctx.user; + const args = ctx.state?.command?.args || []; + const scheduleId = args[0]; + + if (!scheduleId) { + return ctx.reply(ctx.i18n.t('cancelschedule_usage')); + } + + const schedule = await ScheduledOrder.findOne({ + _id: scheduleId, + creator_id: user._id, + active: true, + }); + + if (!schedule) { + return ctx.reply(ctx.i18n.t('schedule_not_found')); + } + + schedule.active = false; + await schedule.save(); + + await ctx.reply(ctx.i18n.t('schedule_cancelled', { scheduleId })); + } catch (error) { + logger.error(error); + } +}; + +// In-memory store for multi-step schedule creation flow +// (keyed by Telegram user id; entries live until confirmed or cancelled) +export interface PendingSchedule { + type: string; + amount: number; + fiatAmount: number[]; + fiatCode: string; + paymentMethod: string; + priceMargin?: number; + days?: number[]; + hour?: number; +} + +export const pendingSchedules = new Map(); + +export const handleScheduleCallback = async ( + ctx: MainContext, + bot: Telegraf, +) => { + const data = (ctx.callbackQuery as any)?.data as string | undefined; + if (!data?.startsWith('sched_')) return false; + + const user = ctx.user; + const pending = pendingSchedules.get(user.tg_id); + if (!pending) { + await ctx.answerCbQuery(); + return true; + } + + // --- Day selection step --- + if (data === 'sched_daily') { + pending.days = PRESET_DAYS.daily; + pendingSchedules.set(user.tg_id, pending); + await ctx.answerCbQuery(); + await messages.askScheduleHour(ctx); + return true; + } + if (data === 'sched_weekdays') { + pending.days = PRESET_DAYS.weekdays; + pendingSchedules.set(user.tg_id, pending); + await ctx.answerCbQuery(); + await messages.askScheduleHour(ctx); + return true; + } + if (data === 'sched_weekend') { + pending.days = PRESET_DAYS.weekend; + pendingSchedules.set(user.tg_id, pending); + await ctx.answerCbQuery(); + await messages.askScheduleHour(ctx); + return true; + } + if (data === 'sched_custom') { + pendingSchedules.set(user.tg_id, { ...pending, days: undefined }); + await ctx.answerCbQuery(); + await messages.askCustomDays(ctx); + return true; + } + + // --- Confirm / cancel --- + if (data === 'sched_confirm') { + await ctx.answerCbQuery(); + await confirmSchedule(ctx, bot); + return true; + } + if (data === 'sched_cancel') { + pendingSchedules.delete(user.tg_id); + await ctx.answerCbQuery(); + await ctx.reply(ctx.i18n.t('schedule_creation_cancelled')); + return true; + } + + await ctx.answerCbQuery(); + return true; +}; + +export const handleScheduleText = async ( + ctx: MainContext, + bot: Telegraf, +): Promise => { + const user = ctx.user; + const pending = pendingSchedules.get(user.tg_id); + if (!pending) return false; + + const text = (ctx.message as any)?.text?.trim(); + if (!text) return false; + + // Waiting for custom days input + if (pending.days === undefined) { + const days = parseCustomDays(text); + if (!days) { + await ctx.reply(ctx.i18n.t('invalid_days')); + return true; + } + pending.days = days; + pendingSchedules.set(user.tg_id, pending); + await messages.askScheduleHour(ctx); + return true; + } + + // Waiting for hour input + if (pending.hour === undefined) { + const hour = parseInt(text); + if (isNaN(hour) || hour < 0 || hour > 23) { + await ctx.reply(ctx.i18n.t('invalid_hour')); + return true; + } + pending.hour = hour; + pendingSchedules.set(user.tg_id, pending); + await messages.askScheduleConfirm(ctx, pending); + return true; + } + + return false; +}; + +const confirmSchedule = async (ctx: MainContext, bot: Telegraf) => { + const user = ctx.user; + const pending = pendingSchedules.get(user.tg_id); + if (!pending || pending.days === undefined || pending.hour === undefined) { + return ctx.reply(ctx.i18n.t('schedule_not_found')); + } + + const schedule = await ScheduledOrder.create({ + creator_id: user._id, + type: pending.type, + amount: pending.amount, + fiat_amount: pending.fiatAmount, + fiat_code: pending.fiatCode, + payment_method: pending.paymentMethod, + price_margin: pending.priceMargin || 0, + days: pending.days, + hour: pending.hour, + republish_count: getRepublishCount(), + active: true, + }); + + pendingSchedules.delete(user.tg_id); + + await ctx.reply(ctx.i18n.t('schedule_created', { scheduleId: schedule._id })); +}; diff --git a/bot/modules/schedule/index.ts b/bot/modules/schedule/index.ts new file mode 100644 index 00000000..05a326cb --- /dev/null +++ b/bot/modules/schedule/index.ts @@ -0,0 +1,25 @@ +import { Telegraf } from 'telegraf'; +import { userMiddleware } from '../../middleware/user'; +import { CommunityContext } from '../community/communityContext'; +import { + scheduleorder, + cancelschedule, + handleScheduleCallback, + handleScheduleText, +} from './commands'; + +export const configure = (bot: Telegraf) => { + bot.command('scheduleorder', userMiddleware, ctx => scheduleorder(ctx)); + bot.command('cancelschedule', userMiddleware, ctx => cancelschedule(ctx)); + + // Multi-step flow: inline button callbacks and free-text replies + bot.on('callback_query', userMiddleware, async (ctx, next) => { + const handled = await handleScheduleCallback(ctx, bot); + if (!handled) return next(); + }); + + bot.on('text', userMiddleware, async (ctx, next) => { + const handled = await handleScheduleText(ctx, bot); + if (!handled) return next(); + }); +}; diff --git a/bot/modules/schedule/messages.ts b/bot/modules/schedule/messages.ts new file mode 100644 index 00000000..c5394516 --- /dev/null +++ b/bot/modules/schedule/messages.ts @@ -0,0 +1,82 @@ +import { MainContext } from '../../start'; +import { PendingSchedule } from './commands'; + +const DAY_LABELS: Record = { + 0: 'Sun', + 1: 'Mon', + 2: 'Tue', + 3: 'Wed', + 4: 'Thu', + 5: 'Fri', + 6: 'Sat', +}; + +const formatDays = (days: number[]): string => + days.map(d => DAY_LABELS[d]).join(', '); + +export const askScheduleDays = async (ctx: MainContext) => { + await ctx.reply(ctx.i18n.t('schedule_choose_days'), { + reply_markup: { + inline_keyboard: [ + [ + { + text: '📅 ' + ctx.i18n.t('sched_daily'), + callback_data: 'sched_daily', + }, + { + text: '💼 ' + ctx.i18n.t('sched_weekdays'), + callback_data: 'sched_weekdays', + }, + ], + [ + { + text: '🌴 ' + ctx.i18n.t('sched_weekend'), + callback_data: 'sched_weekend', + }, + { + text: '⚙️ ' + ctx.i18n.t('sched_custom'), + callback_data: 'sched_custom', + }, + ], + ], + }, + }); +}; + +export const askCustomDays = async (ctx: MainContext) => { + await ctx.reply(ctx.i18n.t('schedule_enter_custom_days')); +}; + +export const askScheduleHour = async (ctx: MainContext) => { + await ctx.reply(ctx.i18n.t('schedule_enter_hour')); +}; + +export const askScheduleConfirm = async ( + ctx: MainContext, + pending: PendingSchedule, +) => { + const days = formatDays(pending.days!); + const hour = String(pending.hour!).padStart(2, '0'); + const summary = ctx.i18n.t('schedule_confirm_summary', { + type: pending.type.toUpperCase(), + fiatAmount: pending.fiatAmount.join('-'), + fiatCode: pending.fiatCode, + paymentMethod: pending.paymentMethod, + days, + hour: `${hour}:00 UTC`, + }); + + await ctx.reply(summary, { + reply_markup: { + inline_keyboard: [ + [ + { + text: '✅ ' + ctx.i18n.t('continue'), + callback_data: 'sched_confirm', + }, + { text: '❌ ' + ctx.i18n.t('cancel'), callback_data: 'sched_cancel' }, + ], + ], + }, + }); +}; diff --git a/bot/start.ts b/bot/start.ts index 16594eb6..72e7d0d7 100644 --- a/bot/start.ts +++ b/bot/start.ts @@ -35,6 +35,7 @@ import * as OrdersModule from './modules/orders'; import * as UserModule from './modules/user'; import * as DisputeModule from './modules/dispute'; import * as BlockModule from './modules/block'; +import * as ScheduleModule from './modules/schedule'; import { rateUser, cancelAddInvoice, @@ -72,6 +73,7 @@ import { nodeInfo, checkSolvers, checkHoldInvoiceExpired, + publishScheduledOrders, } from '../jobs'; import { logger } from '../logger'; import { IUsernameId } from '../models/community'; @@ -237,6 +239,11 @@ const initialize = ( await checkHoldInvoiceExpired(bot); }); + // Publish scheduled orders — runs at the top of every hour (UTC) + schedule.scheduleJob(`0 * * * *`, async () => { + await publishScheduledOrders(bot); + }); + bot.start(async (ctx: MainContext) => { try { if (!('message' in ctx.update) || !('text' in ctx.update.message)) { @@ -300,6 +307,7 @@ const initialize = ( CommunityModule.configure(bot); LanguageModule.configure(bot); BlockModule.configure(bot); + ScheduleModule.configure(bot); bot.command('release', userMiddleware, async ctx => { try { diff --git a/jobs/index.ts b/jobs/index.ts index fce4cbc7..cb22db53 100644 --- a/jobs/index.ts +++ b/jobs/index.ts @@ -9,6 +9,7 @@ import deleteCommunity from './communities'; import nodeInfo from './node_info'; import checkSolvers from './check_solvers'; import checkHoldInvoiceExpired from './check_hold_invoice_expired'; +import publishScheduledOrders from './scheduled_orders'; export { attemptPendingPayments, @@ -20,4 +21,5 @@ export { nodeInfo, checkSolvers, checkHoldInvoiceExpired, + publishScheduledOrders, }; diff --git a/jobs/scheduled_orders.ts b/jobs/scheduled_orders.ts new file mode 100644 index 00000000..daabea2d --- /dev/null +++ b/jobs/scheduled_orders.ts @@ -0,0 +1,89 @@ +import { Telegraf } from 'telegraf'; + +import { ScheduledOrder, User } from '../models'; +import { getUserI18nContext } from '../util'; +import { logger } from '../logger'; +import { CommunityContext } from '../bot/modules/community/communityContext'; +import * as ordersActions from '../bot/ordersActions'; +import { + publishBuyOrderMessage, + publishSellOrderMessage, +} from '../bot/messages'; + +// Runs every hour. For each active ScheduledOrder whose days/hour (UTC) match +// now, publishes a brand-new Order from the stored mold. The mold is never +// mutated into an order; each publication has its own id and lifecycle. +const publishScheduledOrders = async (bot: Telegraf) => { + try { + const now = new Date(); + const currentDay = now.getUTCDay(); // 0 (Sunday) .. 6 (Saturday) + const currentHour = now.getUTCHours(); // 0 .. 23 + + const schedules = await ScheduledOrder.find({ + active: true, + days: currentDay, + hour: currentHour, + }); + + for (const schedule of schedules) { + try { + if (schedule.republish_count <= 0) { + schedule.active = false; + await schedule.save(); + continue; + } + + const user = await User.findById(schedule.creator_id); + if (!user) { + logger.warning( + `ScheduledOrder ${schedule._id}: creator ${schedule.creator_id} not found, deactivating`, + ); + schedule.active = false; + await schedule.save(); + continue; + } + + const i18n = await getUserI18nContext(user); + + const order = await ordersActions.createOrder(i18n, bot, user, { + type: schedule.type, + amount: schedule.amount, + fiatAmount: schedule.fiat_amount, + fiatCode: schedule.fiat_code, + paymentMethod: schedule.payment_method, + status: 'PENDING', + priceMargin: schedule.price_margin, + community_id: schedule.community_id, + }); + + if (!order) { + logger.warning( + `ScheduledOrder ${schedule._id}: failed to create order`, + ); + continue; + } + + const publishFn = + schedule.type === 'buy' + ? publishBuyOrderMessage + : publishSellOrderMessage; + await publishFn(bot, user, order, i18n, false); + + schedule.last_order_id = order._id; + schedule.republish_count -= 1; + if (schedule.republish_count <= 0) schedule.active = false; + await schedule.save(); + + logger.info( + `ScheduledOrder ${schedule._id} published order ${order._id}, ${schedule.republish_count} cycles left`, + ); + } catch (error) { + logger.error(`publishScheduledOrders inner error: ${String(error)}`); + } + } + } catch (error) { + logger.error(`publishScheduledOrders error: ${String(error)}`); + } +}; + +export default publishScheduledOrders; diff --git a/locales/en.yaml b/locales/en.yaml index 83b202c2..e912ea43 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -265,6 +265,8 @@ help: | /blocklist - List of your blocked users /cancel <_order id_> - Cancel an order that has not been taken yet /cancelall - Cancel all posted and untaken orders + /scheduleorder <_buy|sell_> <_sats_> <_fiat amount_> <_fiat code_> <_payment method_> [_premium_] - Creates a recurring order that auto-republishes + /cancelschedule <_schedule id_> - Stops auto-republishing of a scheduled order /terms - Shows the terms and conditions when using the bot Nostr: @@ -731,3 +733,28 @@ community_enabled_by_admin: An administrator has re-enabled your community ${com community_already_disabled: This community is already disabled community_already_enabled: This community is already enabled ambiguous_community_input: Multiple communities match that username, please use the community ID instead +scheduleorder_usage: "Usage: /scheduleorder [premium]" +cancelschedule_usage: "Usage: /cancelschedule " +schedule_not_found: "⚠️ Schedule not found or already inactive." +schedule_cancelled: "✅ Schedule ${scheduleId} cancelled. No more orders will be auto-published." +schedule_creation_cancelled: "❌ Schedule creation cancelled." +schedule_created: "✅ Scheduled order created! Your ID is: ${scheduleId}\nUse /cancelschedule ${scheduleId} to stop it." +schedule_choose_days: "📅 On which days should the order be published?" +sched_daily: Every day +sched_weekdays: Mon–Fri +sched_weekend: Weekend +sched_custom: Custom +schedule_enter_custom_days: "✍️ Enter the days separated by commas (e.g.: monday, wednesday, friday)" +invalid_days: "⚠️ I couldn't understand those days. Please enter them separated by commas (e.g.: monday, wednesday, friday)." +schedule_enter_hour: "🕐 At what hour should the order be published? Enter a number between 0 and 23 (UTC)." +invalid_hour: "⚠️ Invalid hour. Please enter a number between 0 and 23." +schedule_confirm_summary: | + 📋 *Order schedule summary* + + Type: ${type} + Amount: ${fiatAmount} ${fiatCode} + Payment method: ${paymentMethod} + Days: ${days} + Hour: ${hour} + + Confirm? diff --git a/models/index.ts b/models/index.ts index f68837bd..2d697043 100644 --- a/models/index.ts +++ b/models/index.ts @@ -5,5 +5,15 @@ import Community from './community'; import Dispute from './dispute'; import Config from './config'; import Block from './block'; +import ScheduledOrder from './scheduled_order'; -export { User, Order, PendingPayment, Community, Dispute, Config, Block }; +export { + User, + Order, + PendingPayment, + Community, + Dispute, + Config, + Block, + ScheduledOrder, +}; diff --git a/models/scheduled_order.ts b/models/scheduled_order.ts new file mode 100644 index 00000000..5f3e42c9 --- /dev/null +++ b/models/scheduled_order.ts @@ -0,0 +1,45 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +export interface IScheduledOrder extends Document { + _id: string; + creator_id: string; + type: string; + amount: number; + fiat_amount: number[]; + fiat_code: string; + payment_method: string; + price_margin: number; + community_id?: string; + // Weekdays (0=Sunday .. 6=Saturday, UTC) on which the order is published + days: number[]; + // Hour of the day (0-23, UTC) at which the order is published + hour: number; + // Remaining publication cycles before the schedule is exhausted + republish_count: number; + // The last order published from this schedule (mold -> order link) + last_order_id?: string | null; + active: boolean; + created_at: Date; +} + +const scheduledOrderSchema = new Schema({ + creator_id: { type: String, required: true }, + type: { type: String, required: true }, + amount: { type: Number, required: true }, + fiat_amount: { type: [Number], required: true }, + fiat_code: { type: String, required: true }, + payment_method: { type: String, required: true }, + price_margin: { type: Number, default: 0 }, + community_id: { type: String }, + days: { type: [Number], required: true }, + hour: { type: Number, required: true, min: 0, max: 23 }, + republish_count: { type: Number, default: 0 }, + last_order_id: { type: String, default: null }, + active: { type: Boolean, default: true }, + created_at: { type: Date, default: Date.now }, +}); + +export default mongoose.model( + 'ScheduledOrder', + scheduledOrderSchema, +); diff --git a/tests/bot/bot.spec.ts b/tests/bot/bot.spec.ts index b9877d98..7be30b6d 100644 --- a/tests/bot/bot.spec.ts +++ b/tests/bot/bot.spec.ts @@ -463,7 +463,7 @@ describe('Bot Initialization', () => { scheduledFunction(); - expect(scheduleStub.scheduleJob.callCount).to.be.equal(8); + expect(scheduleStub.scheduleJob.callCount).to.be.equal(9); expect(scheduleStub.scheduleJob.getCall(0).args[0]).to.equal( '*/10 * * * *', ); From d867784a5cd97e917b4aa39d086ff7cbbbbe70a5 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 29 Jun 2026 18:59:32 -0300 Subject: [PATCH 02/19] refactor: migrate schedule flow from global listeners to WizardScene --- bot/middleware/stage.ts | 2 + bot/modules/orders/takeOrder.ts | 8 +- bot/modules/schedule/commands.ts | 248 ++----------------------------- bot/modules/schedule/helpers.ts | 64 ++++++++ bot/modules/schedule/index.ts | 24 +-- bot/modules/schedule/messages.ts | 35 +++-- bot/modules/schedule/scenes.ts | 148 ++++++++++++++++++ 7 files changed, 257 insertions(+), 272 deletions(-) create mode 100644 bot/modules/schedule/helpers.ts create mode 100644 bot/modules/schedule/scenes.ts diff --git a/bot/middleware/stage.ts b/bot/middleware/stage.ts index 96bd2ebd..d7b90d8a 100644 --- a/bot/middleware/stage.ts +++ b/bot/middleware/stage.ts @@ -2,6 +2,7 @@ import { Scenes } from 'telegraf'; import * as CommunityModule from '../modules/community'; import * as OrdersModule from '../modules/orders'; import * as UserModule from '../modules/user'; +import { scheduleOrderWizard } from '../modules/schedule'; import { CommunityContext } from '../modules/community/communityContext'; import { addInvoiceWizard, @@ -28,6 +29,7 @@ export const stageMiddleware = () => { addInvoicePHIWizard, OrdersModule.Scenes.createOrder, UserModule.Scenes.Settings, + scheduleOrderWizard, ]; scenes.forEach(addGenericCommands); const stage = new Scenes.Stage(scenes, { diff --git a/bot/modules/orders/takeOrder.ts b/bot/modules/orders/takeOrder.ts index b4b4a0f7..a505771b 100644 --- a/bot/modules/orders/takeOrder.ts +++ b/bot/modules/orders/takeOrder.ts @@ -17,6 +17,7 @@ import { validateTakeSellOrder, validateUserWaitingOrder, } from '../../validations'; +import { getRepublishCount } from '../schedule/helpers'; const OrderEvents = require('../../modules/events/orders'); @@ -162,11 +163,6 @@ const refreshScheduledOrder = async ( bot: HasTelegram, ): Promise => { try { - const REPUBLISH_DAYS_DEFAULT = 10; - const raw = parseInt(process.env.REPUBLISH_ORDER_DAYS || ''); - const republishCount = - Number.isInteger(raw) && raw > 0 ? raw : REPUBLISH_DAYS_DEFAULT; - const schedule = await ScheduledOrder.findOne({ last_order_id: orderId, active: true, @@ -204,7 +200,7 @@ const refreshScheduledOrder = async ( await publishFn(bot, creator, newOrder, i18n, false); schedule.last_order_id = newOrder._id; - schedule.republish_count = republishCount; + schedule.republish_count = getRepublishCount(); await schedule.save(); } catch (error) { logger.error(`refreshScheduledOrder error: ${String(error)}`); diff --git a/bot/modules/schedule/commands.ts b/bot/modules/schedule/commands.ts index 44d3a213..0300ff5b 100644 --- a/bot/modules/schedule/commands.ts +++ b/bot/modules/schedule/commands.ts @@ -1,88 +1,15 @@ -import { Telegraf } from 'telegraf'; -import { MainContext } from '../../start'; +import { CommunityContext } from '../community/communityContext'; import { ScheduledOrder } from '../../../models'; import { validateSellOrder, validateBuyOrder } from '../../validations'; import { getCurrency } from '../../../util'; import { logger } from '../../../logger'; -import * as messages from './messages'; +import { SCHEDULE_ORDER } from './scenes'; -const REPUBLISH_DAYS_DEFAULT = 10; - -const getRepublishCount = () => { - const raw = parseInt(process.env.REPUBLISH_ORDER_DAYS || ''); - return Number.isInteger(raw) && raw > 0 ? raw : REPUBLISH_DAYS_DEFAULT; -}; - -// Day names (Spanish, English, abbreviated) mapped to getUTCDay() values (0=Sun) -const DAY_ALIASES: Record = { - domingo: 0, - sunday: 0, - sun: 0, - dom: 0, - lunes: 1, - monday: 1, - mon: 1, - lun: 1, - martes: 2, - tuesday: 2, - tue: 2, - mar: 2, - miercoles: 3, - miércoles: 3, - wednesday: 3, - wed: 3, - mie: 3, - mié: 3, - jueves: 4, - thursday: 4, - thu: 4, - jue: 4, - viernes: 5, - friday: 5, - fri: 5, - vie: 5, - sabado: 6, - sábado: 6, - saturday: 6, - sat: 6, - sab: 6, - sáb: 6, -}; - -export const parseCustomDays = (input: string): number[] | null => { - const parts = input - .toLowerCase() - .split(/[,\s]+/) - .filter(Boolean); - if (parts.length === 0) return null; - const days = new Set(); - for (const part of parts) { - const normalized = part.normalize('NFD').replace(/[̀-ͯ]/g, ''); - const dayNum = - DAY_ALIASES[part] ?? - DAY_ALIASES[normalized] ?? - DAY_ALIASES[ - part.replace( - /[áéíóú]/g, - c => ({ á: 'a', é: 'e', í: 'i', ó: 'o', ú: 'u' })[c] || c, - ) - ]; - if (dayNum === undefined) return null; - days.add(dayNum); - } - return [...days].sort(); -}; - -// Presets -const PRESET_DAYS: Record = { - daily: [0, 1, 2, 3, 4, 5, 6], - weekdays: [1, 2, 3, 4, 5], - weekend: [0, 6], -}; - -export const scheduleorder = async (ctx: MainContext) => { +export const scheduleorder = async (ctx: CommunityContext) => { try { - const user = ctx.user; + // The scheduling flow is an interactive DM wizard + if (ctx.message?.chat.type !== 'private') return; + const text = (ctx.message as any)?.text || ''; const [, type, ...rest] = text.trim().split(/\s+/); @@ -92,8 +19,7 @@ export const scheduleorder = async (ctx: MainContext) => { const orderType = type.toLowerCase(); - // Reuse existing validators by temporarily rewriting ctx.state.command.args - const originalArgs = ctx.state?.command?.args; + // Reuse the existing buy/sell validators by feeding them the order args if (!ctx.state) (ctx as any).state = {}; if (!ctx.state.command) (ctx as any).state.command = {}; ctx.state.command.args = rest; @@ -103,8 +29,6 @@ export const scheduleorder = async (ctx: MainContext) => { ? await validateSellOrder(ctx) : await validateBuyOrder(ctx); - ctx.state.command.args = originalArgs; - if (!params) return; if (!getCurrency(params.fiatCode)) { @@ -112,20 +36,21 @@ export const scheduleorder = async (ctx: MainContext) => { return; } - // Store pending schedule params in session-like state via a temporary - // message-level store keyed by user tg_id (in-memory, ephemeral) - pendingSchedules.set(user.tg_id, { - type: orderType, - ...params, + await ctx.scene.enter(SCHEDULE_ORDER, { + user: ctx.user, + scheduleType: orderType, + amount: params.amount, + fiatAmount: params.fiatAmount, + fiatCode: params.fiatCode, + paymentMethod: params.paymentMethod, + priceMargin: params.priceMargin, }); - - await messages.askScheduleDays(ctx); } catch (error) { logger.error(error); } }; -export const cancelschedule = async (ctx: MainContext) => { +export const cancelschedule = async (ctx: CommunityContext) => { try { const user = ctx.user; const args = ctx.state?.command?.args || []; @@ -153,144 +78,3 @@ export const cancelschedule = async (ctx: MainContext) => { logger.error(error); } }; - -// In-memory store for multi-step schedule creation flow -// (keyed by Telegram user id; entries live until confirmed or cancelled) -export interface PendingSchedule { - type: string; - amount: number; - fiatAmount: number[]; - fiatCode: string; - paymentMethod: string; - priceMargin?: number; - days?: number[]; - hour?: number; -} - -export const pendingSchedules = new Map(); - -export const handleScheduleCallback = async ( - ctx: MainContext, - bot: Telegraf, -) => { - const data = (ctx.callbackQuery as any)?.data as string | undefined; - if (!data?.startsWith('sched_')) return false; - - const user = ctx.user; - const pending = pendingSchedules.get(user.tg_id); - if (!pending) { - await ctx.answerCbQuery(); - return true; - } - - // --- Day selection step --- - if (data === 'sched_daily') { - pending.days = PRESET_DAYS.daily; - pendingSchedules.set(user.tg_id, pending); - await ctx.answerCbQuery(); - await messages.askScheduleHour(ctx); - return true; - } - if (data === 'sched_weekdays') { - pending.days = PRESET_DAYS.weekdays; - pendingSchedules.set(user.tg_id, pending); - await ctx.answerCbQuery(); - await messages.askScheduleHour(ctx); - return true; - } - if (data === 'sched_weekend') { - pending.days = PRESET_DAYS.weekend; - pendingSchedules.set(user.tg_id, pending); - await ctx.answerCbQuery(); - await messages.askScheduleHour(ctx); - return true; - } - if (data === 'sched_custom') { - pendingSchedules.set(user.tg_id, { ...pending, days: undefined }); - await ctx.answerCbQuery(); - await messages.askCustomDays(ctx); - return true; - } - - // --- Confirm / cancel --- - if (data === 'sched_confirm') { - await ctx.answerCbQuery(); - await confirmSchedule(ctx, bot); - return true; - } - if (data === 'sched_cancel') { - pendingSchedules.delete(user.tg_id); - await ctx.answerCbQuery(); - await ctx.reply(ctx.i18n.t('schedule_creation_cancelled')); - return true; - } - - await ctx.answerCbQuery(); - return true; -}; - -export const handleScheduleText = async ( - ctx: MainContext, - bot: Telegraf, -): Promise => { - const user = ctx.user; - const pending = pendingSchedules.get(user.tg_id); - if (!pending) return false; - - const text = (ctx.message as any)?.text?.trim(); - if (!text) return false; - - // Waiting for custom days input - if (pending.days === undefined) { - const days = parseCustomDays(text); - if (!days) { - await ctx.reply(ctx.i18n.t('invalid_days')); - return true; - } - pending.days = days; - pendingSchedules.set(user.tg_id, pending); - await messages.askScheduleHour(ctx); - return true; - } - - // Waiting for hour input - if (pending.hour === undefined) { - const hour = parseInt(text); - if (isNaN(hour) || hour < 0 || hour > 23) { - await ctx.reply(ctx.i18n.t('invalid_hour')); - return true; - } - pending.hour = hour; - pendingSchedules.set(user.tg_id, pending); - await messages.askScheduleConfirm(ctx, pending); - return true; - } - - return false; -}; - -const confirmSchedule = async (ctx: MainContext, bot: Telegraf) => { - const user = ctx.user; - const pending = pendingSchedules.get(user.tg_id); - if (!pending || pending.days === undefined || pending.hour === undefined) { - return ctx.reply(ctx.i18n.t('schedule_not_found')); - } - - const schedule = await ScheduledOrder.create({ - creator_id: user._id, - type: pending.type, - amount: pending.amount, - fiat_amount: pending.fiatAmount, - fiat_code: pending.fiatCode, - payment_method: pending.paymentMethod, - price_margin: pending.priceMargin || 0, - days: pending.days, - hour: pending.hour, - republish_count: getRepublishCount(), - active: true, - }); - - pendingSchedules.delete(user.tg_id); - - await ctx.reply(ctx.i18n.t('schedule_created', { scheduleId: schedule._id })); -}; diff --git a/bot/modules/schedule/helpers.ts b/bot/modules/schedule/helpers.ts new file mode 100644 index 00000000..a3548da1 --- /dev/null +++ b/bot/modules/schedule/helpers.ts @@ -0,0 +1,64 @@ +const REPUBLISH_DAYS_DEFAULT = 10; + +export const getRepublishCount = (): number => { + const raw = parseInt(process.env.REPUBLISH_ORDER_DAYS || ''); + return Number.isInteger(raw) && raw > 0 ? raw : REPUBLISH_DAYS_DEFAULT; +}; + +// Day names (Spanish, English, abbreviated) mapped to getUTCDay() values (0=Sun) +const DAY_ALIASES: Record = { + domingo: 0, + sunday: 0, + sun: 0, + dom: 0, + lunes: 1, + monday: 1, + mon: 1, + lun: 1, + martes: 2, + tuesday: 2, + tue: 2, + mar: 2, + miercoles: 3, + wednesday: 3, + wed: 3, + mie: 3, + jueves: 4, + thursday: 4, + thu: 4, + jue: 4, + viernes: 5, + friday: 5, + fri: 5, + vie: 5, + sabado: 6, + saturday: 6, + sat: 6, + sab: 6, +}; + +// Parses a comma/space separated list of day names into sorted weekday numbers. +// Tolerant to accents, casing and extra whitespace. Returns null if any token +// is not a recognized day. +export const parseCustomDays = (input: string): number[] | null => { + const parts = input + .toLowerCase() + .split(/[,\s]+/) + .filter(Boolean); + if (parts.length === 0) return null; + const days = new Set(); + for (const part of parts) { + // strip accents so "miércoles" -> "miercoles" + const normalized = part.normalize('NFD').replace(/[̀-ͯ]/g, ''); + const dayNum = DAY_ALIASES[normalized]; + if (dayNum === undefined) return null; + days.add(dayNum); + } + return [...days].sort((a, b) => a - b); +}; + +export const PRESET_DAYS: Record = { + daily: [0, 1, 2, 3, 4, 5, 6], + weekdays: [1, 2, 3, 4, 5], + weekend: [0, 6], +}; diff --git a/bot/modules/schedule/index.ts b/bot/modules/schedule/index.ts index 05a326cb..9281fa45 100644 --- a/bot/modules/schedule/index.ts +++ b/bot/modules/schedule/index.ts @@ -1,25 +1,11 @@ import { Telegraf } from 'telegraf'; import { userMiddleware } from '../../middleware/user'; import { CommunityContext } from '../community/communityContext'; -import { - scheduleorder, - cancelschedule, - handleScheduleCallback, - handleScheduleText, -} from './commands'; +import { scheduleorder, cancelschedule } from './commands'; -export const configure = (bot: Telegraf) => { - bot.command('scheduleorder', userMiddleware, ctx => scheduleorder(ctx)); - bot.command('cancelschedule', userMiddleware, ctx => cancelschedule(ctx)); - - // Multi-step flow: inline button callbacks and free-text replies - bot.on('callback_query', userMiddleware, async (ctx, next) => { - const handled = await handleScheduleCallback(ctx, bot); - if (!handled) return next(); - }); +export { scheduleOrderWizard } from './scenes'; - bot.on('text', userMiddleware, async (ctx, next) => { - const handled = await handleScheduleText(ctx, bot); - if (!handled) return next(); - }); +export const configure = (bot: Telegraf) => { + bot.command('scheduleorder', userMiddleware, scheduleorder); + bot.command('cancelschedule', userMiddleware, cancelschedule); }; diff --git a/bot/modules/schedule/messages.ts b/bot/modules/schedule/messages.ts index c5394516..b7f90bcc 100644 --- a/bot/modules/schedule/messages.ts +++ b/bot/modules/schedule/messages.ts @@ -1,5 +1,4 @@ -import { MainContext } from '../../start'; -import { PendingSchedule } from './commands'; +import { CommunityContext } from '../community/communityContext'; const DAY_LABELS: Record = { 0: 'Sun', @@ -11,10 +10,10 @@ const DAY_LABELS: Record = { 6: 'Sat', }; -const formatDays = (days: number[]): string => +export const formatDays = (days: number[]): string => days.map(d => DAY_LABELS[d]).join(', '); -export const askScheduleDays = async (ctx: MainContext) => { +export const askScheduleDays = async (ctx: CommunityContext) => { await ctx.reply(ctx.i18n.t('schedule_choose_days'), { reply_markup: { inline_keyboard: [ @@ -43,26 +42,32 @@ export const askScheduleDays = async (ctx: MainContext) => { }); }; -export const askCustomDays = async (ctx: MainContext) => { +export const askCustomDays = async (ctx: CommunityContext) => { await ctx.reply(ctx.i18n.t('schedule_enter_custom_days')); }; -export const askScheduleHour = async (ctx: MainContext) => { +export const askScheduleHour = async (ctx: CommunityContext) => { await ctx.reply(ctx.i18n.t('schedule_enter_hour')); }; export const askScheduleConfirm = async ( - ctx: MainContext, - pending: PendingSchedule, + ctx: CommunityContext, + summaryData: { + type: string; + fiatAmount: number[]; + fiatCode: string; + paymentMethod: string; + days: number[]; + hour: number; + }, ) => { - const days = formatDays(pending.days!); - const hour = String(pending.hour!).padStart(2, '0'); + const hour = String(summaryData.hour).padStart(2, '0'); const summary = ctx.i18n.t('schedule_confirm_summary', { - type: pending.type.toUpperCase(), - fiatAmount: pending.fiatAmount.join('-'), - fiatCode: pending.fiatCode, - paymentMethod: pending.paymentMethod, - days, + type: summaryData.type.toUpperCase(), + fiatAmount: summaryData.fiatAmount.join('-'), + fiatCode: summaryData.fiatCode, + paymentMethod: summaryData.paymentMethod, + days: formatDays(summaryData.days), hour: `${hour}:00 UTC`, }); diff --git a/bot/modules/schedule/scenes.ts b/bot/modules/schedule/scenes.ts new file mode 100644 index 00000000..20686a53 --- /dev/null +++ b/bot/modules/schedule/scenes.ts @@ -0,0 +1,148 @@ +import { Scenes } from 'telegraf'; +import { logger } from '../../../logger'; +import { ScheduledOrder } from '../../../models'; +import { CommunityContext } from '../community/communityContext'; +import { UserDocument } from '../../../models/user'; +import * as messages from './messages'; +import { getRepublishCount, parseCustomDays, PRESET_DAYS } from './helpers'; + +export const SCHEDULE_ORDER = 'SCHEDULE_ORDER_WIZARD'; + +// Shape of the data carried in the wizard state for this scene. +interface ScheduleWizardState { + user: UserDocument; + scheduleType: string; + amount: number; + fiatAmount: number[]; + fiatCode: string; + paymentMethod: string; + priceMargin?: number | string; + awaitingCustomDays?: boolean; + days?: number[]; + hour?: number; +} + +const getState = (ctx: CommunityContext) => + ctx.wizard.state as unknown as ScheduleWizardState; + +export const scheduleOrderWizard = new Scenes.WizardScene( + SCHEDULE_ORDER, + // Step 0: runs on enter — ask for the days + async (ctx: CommunityContext) => { + try { + await messages.askScheduleDays(ctx); + return ctx.wizard.next(); + } catch (err) { + logger.error(err); + return ctx.scene.leave(); + } + }, + // Step 1: handle day selection (preset buttons, custom button, or custom text) + async (ctx: CommunityContext) => { + try { + const state = getState(ctx); + + // User pressed one of the day buttons + const data = (ctx.callbackQuery as any)?.data as string | undefined; + if (data) { + await ctx.answerCbQuery(); + if (data === 'sched_custom') { + state.awaitingCustomDays = true; + await messages.askCustomDays(ctx); + return; // stay on this step, waiting for the text + } + if (PRESET_DAYS[data.replace('sched_', '')]) { + state.days = PRESET_DAYS[data.replace('sched_', '')]; + await messages.askScheduleHour(ctx); + return ctx.wizard.next(); + } + return; // ignore other callbacks + } + + // User typed the custom days + if (state.awaitingCustomDays) { + const text = (ctx.message as any)?.text?.trim(); + const days = text ? parseCustomDays(text) : null; + if (!days) { + await ctx.reply(ctx.i18n.t('invalid_days')); + return; // stay, reprompt + } + state.days = days; + await messages.askScheduleHour(ctx); + return ctx.wizard.next(); + } + } catch (err) { + logger.error(err); + return ctx.scene.leave(); + } + }, + // Step 2: handle hour input (text) + async (ctx: CommunityContext) => { + try { + const state = getState(ctx); + const text = (ctx.message as any)?.text?.trim(); + const hour = parseInt(text); + if (isNaN(hour) || hour < 0 || hour > 23) { + await ctx.reply(ctx.i18n.t('invalid_hour')); + return; // stay, reprompt + } + state.hour = hour; + await messages.askScheduleConfirm(ctx, { + type: state.scheduleType, + fiatAmount: state.fiatAmount, + fiatCode: state.fiatCode, + paymentMethod: state.paymentMethod, + days: state.days!, + hour: state.hour, + }); + return ctx.wizard.next(); + } catch (err) { + logger.error(err); + return ctx.scene.leave(); + } + }, + // Step 3: handle confirm / cancel buttons + async (ctx: CommunityContext) => { + try { + const data = (ctx.callbackQuery as any)?.data as string | undefined; + if (!data) return; // ignore stray text, wait for a button + + await ctx.answerCbQuery(); + + if (data === 'sched_cancel') { + await ctx.reply(ctx.i18n.t('schedule_creation_cancelled')); + return ctx.scene.leave(); + } + + if (data !== 'sched_confirm') return; + + const state = getState(ctx); + if (state.days === undefined || state.hour === undefined) { + await ctx.reply(ctx.i18n.t('schedule_not_found')); + return ctx.scene.leave(); + } + + const schedule = await ScheduledOrder.create({ + creator_id: state.user._id, + type: state.scheduleType, + amount: state.amount, + fiat_amount: state.fiatAmount, + fiat_code: state.fiatCode, + payment_method: state.paymentMethod, + price_margin: Number(state.priceMargin) || 0, + days: state.days, + hour: state.hour, + republish_count: getRepublishCount(), + active: true, + }); + + await ctx.reply( + ctx.i18n.t('schedule_created', { scheduleId: schedule._id }), + ); + return ctx.scene.leave(); + } catch (err) { + logger.error(err); + return ctx.scene.leave(); + } + }, +); From f73a43291debfb6aeda8647b7d2a2f035f8a0945 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 29 Jun 2026 19:09:10 -0300 Subject: [PATCH 03/19] i18n: translate schedule command strings to all 9 supported locales --- locales/de.yaml | 24 ++++++++++++++++++++++++ locales/es.yaml | 24 ++++++++++++++++++++++++ locales/fa.yaml | 24 ++++++++++++++++++++++++ locales/fr.yaml | 24 ++++++++++++++++++++++++ locales/it.yaml | 24 ++++++++++++++++++++++++ locales/ko.yaml | 24 ++++++++++++++++++++++++ locales/pt.yaml | 24 ++++++++++++++++++++++++ locales/ru.yaml | 24 ++++++++++++++++++++++++ locales/uk.yaml | 24 ++++++++++++++++++++++++ 9 files changed, 216 insertions(+) diff --git a/locales/de.yaml b/locales/de.yaml index 36dfbaba..6374c5f4 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -725,3 +725,27 @@ community_enabled_by_admin: Ein Administrator hat deine Community ${communityNam community_already_disabled: Diese Community ist bereits deaktiviert community_already_enabled: Diese Community ist bereits aktiviert ambiguous_community_input: Mehrere Communities stimmen mit diesem Benutzernamen überein, bitte verwende stattdessen die Community-ID + +scheduleorder_usage: "Verwendung: /scheduleorder [Aufschlag]" +cancelschedule_usage: "Verwendung: /cancelschedule " +schedule_not_found: "⚠️ Schedule nicht gefunden oder bereits inaktiv." +schedule_cancelled: "✅ Schedule ${scheduleId} abgebrochen. Es werden keine Bestellungen mehr automatisch veröffentlicht." +schedule_creation_cancelled: "❌ Erstellung des Schedules abgebrochen." +schedule_created: "✅ Geplante Bestellung erstellt! Deine ID ist: ${scheduleId}\nVerwende /cancelschedule ${scheduleId} zum Stoppen." +schedule_choose_days: "📅 An welchen Tagen soll die Bestellung veröffentlicht werden?" +sched_daily: Jeden Tag +sched_weekdays: Mo–Fr +sched_weekend: Wochenende +sched_custom: Benutzerdefiniert +schedule_enter_custom_days: "✍️ Gib die Tage durch Kommas getrennt ein (z.B.: montag, mittwoch, freitag)" +invalid_days: "⚠️ Ich konnte diese Tage nicht verstehen. Bitte gib sie durch Kommas getrennt ein (z.B.: montag, mittwoch, freitag)." +schedule_enter_hour: "🕐 Zu welcher Uhrzeit soll die Bestellung veröffentlicht werden? Gib eine Zahl zwischen 0 und 23 ein (UTC)." +invalid_hour: "⚠️ Ungültige Stunde. Bitte gib eine Zahl zwischen 0 und 23 ein." +schedule_confirm_summary: | + 📋 *Zusammenfassung der geplanten Bestellung* + + Typ: ${type} + Betrag: ${fiatAmount} ${fiatCode} + Zahlungsmethode: ${paymentMethod} + Tage: ${days} + Uhrzeit: ${hour} diff --git a/locales/es.yaml b/locales/es.yaml index e54b73a3..3be27d6d 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -728,3 +728,27 @@ community_enabled_by_admin: Un administrador ha reactivado tu comunidad ${commun community_already_disabled: Esta comunidad ya está deshabilitada community_already_enabled: Esta comunidad ya está habilitada ambiguous_community_input: Varias comunidades coinciden con ese nombre de usuario, por favor usa el ID de la comunidad + +scheduleorder_usage: "Uso: /scheduleorder [prima]" +cancelschedule_usage: "Uso: /cancelschedule " +schedule_not_found: "⚠️ Schedule no encontrado o ya inactivo." +schedule_cancelled: "✅ Schedule ${scheduleId} cancelado. No se publicarán más órdenes automáticamente." +schedule_creation_cancelled: "❌ Creación de schedule cancelada." +schedule_created: "✅ Orden programada creada. Tu ID es: ${scheduleId}\nUsa /cancelschedule ${scheduleId} para detenerla." +schedule_choose_days: "📅 ¿En qué días debe publicarse la orden?" +sched_daily: Todos los días +sched_weekdays: Lun–Vie +sched_weekend: Fin de semana +sched_custom: Personalizado +schedule_enter_custom_days: "✍️ Ingresa los días separados por comas (ej: lunes, miércoles, viernes)" +invalid_days: "⚠️ No pude entender esos días. Por favor ingrésalos separados por comas (ej: lunes, miércoles, viernes)." +schedule_enter_hour: "🕐 ¿A qué hora debe publicarse la orden? Ingresa un número entre 0 y 23 (UTC)." +invalid_hour: "⚠️ Hora inválida. Por favor ingresa un número entre 0 y 23." +schedule_confirm_summary: | + 📋 *Resumen de la orden programada* + + Tipo: ${type} + Monto: ${fiatAmount} ${fiatCode} + Método de pago: ${paymentMethod} + Días: ${days} + Hora: ${hour} diff --git a/locales/fa.yaml b/locales/fa.yaml index 002f6e87..61f736d1 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -824,3 +824,27 @@ community_enabled_by_admin: یک مدیر جامعه ${communityName} شما ر community_already_disabled: این جامعه از قبل غیرفعال است community_already_enabled: این جامعه از قبل فعال است ambiguous_community_input: چندین جامعه با این نام کاربری مطابقت دارند، لطفاً از شناسه جامعه استفاده کنید + +scheduleorder_usage: "راهنما: /scheduleorder <سات> <مبلغ فیات> <کد فیات> <روش پرداخت> [پریمیوم]" +cancelschedule_usage: "راهنما: /cancelschedule <شناسه زمان‌بندی>" +schedule_not_found: "⚠️ زمان‌بندی پیدا نشد یا قبلاً غیرفعال شده است." +schedule_cancelled: "✅ زمان‌بندی ${scheduleId} لغو شد. دیگر هیچ سفارشی به‌صورت خودکار منتشر نخواهد شد." +schedule_creation_cancelled: "❌ ایجاد زمان‌بندی لغو شد." +schedule_created: "✅ سفارش زمان‌بندی‌شده ایجاد شد! شناسه شما: ${scheduleId}\nبرای توقف از /cancelschedule ${scheduleId} استفاده کنید." +schedule_choose_days: "📅 در چه روزهایی سفارش منتشر شود؟" +sched_daily: هر روز +sched_weekdays: دوشنبه–جمعه +sched_weekend: آخر هفته +sched_custom: سفارشی +schedule_enter_custom_days: "✍️ روزها را با کاما وارد کنید (مثال: monday, wednesday, friday)" +invalid_days: "⚠️ روزها را نتوانستم تشخیص دهم. لطفاً با کاما جدا کنید (مثال: monday, wednesday, friday)." +schedule_enter_hour: "🕐 سفارش در چه ساعتی منتشر شود؟ عددی بین ۰ تا ۲۳ وارد کنید (UTC)." +invalid_hour: "⚠️ ساعت نامعتبر است. لطفاً عددی بین ۰ تا ۲۳ وارد کنید." +schedule_confirm_summary: | + 📋 *خلاصه سفارش زمان‌بندی‌شده* + + نوع: ${type} + مبلغ: ${fiatAmount} ${fiatCode} + روش پرداخت: ${paymentMethod} + روزها: ${days} + ساعت: ${hour} diff --git a/locales/fr.yaml b/locales/fr.yaml index a6decd9b..6e149611 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -724,3 +724,27 @@ community_enabled_by_admin: Un administrateur a réactivé votre communauté ${c community_already_disabled: Cette communauté est déjà désactivée community_already_enabled: Cette communauté est déjà activée ambiguous_community_input: Plusieurs communautés correspondent à ce nom d'utilisateur, veuillez utiliser l'ID de la communauté à la place + +scheduleorder_usage: "Utilisation : /scheduleorder [prime]" +cancelschedule_usage: "Utilisation : /cancelschedule " +schedule_not_found: "⚠️ Schedule introuvable ou déjà inactif." +schedule_cancelled: "✅ Schedule ${scheduleId} annulé. Aucune autre commande ne sera publiée automatiquement." +schedule_creation_cancelled: "❌ Création du schedule annulée." +schedule_created: "✅ Ordre planifié créé ! Votre ID est : ${scheduleId}\nUtilisez /cancelschedule ${scheduleId} pour l'arrêter." +schedule_choose_days: "📅 Quels jours l'ordre doit-il être publié ?" +sched_daily: Tous les jours +sched_weekdays: Lun–Ven +sched_weekend: Week-end +sched_custom: Personnalisé +schedule_enter_custom_days: "✍️ Entrez les jours séparés par des virgules (ex : lundi, mercredi, vendredi)" +invalid_days: "⚠️ Je n'ai pas compris ces jours. Veuillez les entrer séparés par des virgules (ex : lundi, mercredi, vendredi)." +schedule_enter_hour: "🕐 À quelle heure l'ordre doit-il être publié ? Entrez un nombre entre 0 et 23 (UTC)." +invalid_hour: "⚠️ Heure invalide. Veuillez entrer un nombre entre 0 et 23." +schedule_confirm_summary: | + 📋 *Résumé de l'ordre planifié* + + Type : ${type} + Montant : ${fiatAmount} ${fiatCode} + Méthode de paiement : ${paymentMethod} + Jours : ${days} + Heure : ${hour} diff --git a/locales/it.yaml b/locales/it.yaml index 0e47fe04..d24eb0a9 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -722,3 +722,27 @@ community_enabled_by_admin: Un amministratore ha riabilitato la tua comunità ${ community_already_disabled: Questa comunità è già disabilitata community_already_enabled: Questa comunità è già abilitata ambiguous_community_input: Più comunità corrispondono a quel nome utente, usa invece l'ID della comunità + +scheduleorder_usage: "Utilizzo: /scheduleorder [premio]" +cancelschedule_usage: "Utilizzo: /cancelschedule " +schedule_not_found: "⚠️ Schedule non trovato o già inattivo." +schedule_cancelled: "✅ Schedule ${scheduleId} annullato. Non verranno più pubblicate offerte automaticamente." +schedule_creation_cancelled: "❌ Creazione dello schedule annullata." +schedule_created: "✅ Ordine programmato creato! Il tuo ID è: ${scheduleId}\nUsa /cancelschedule ${scheduleId} per fermarlo." +schedule_choose_days: "📅 In quali giorni deve essere pubblicato l'ordine?" +sched_daily: Ogni giorno +sched_weekdays: Lun–Ven +sched_weekend: Weekend +sched_custom: Personalizzato +schedule_enter_custom_days: "✍️ Inserisci i giorni separati da virgole (es: lunedì, mercoledì, venerdì)" +invalid_days: "⚠️ Non ho capito quei giorni. Inseriscili separati da virgole (es: lunedì, mercoledì, venerdì)." +schedule_enter_hour: "🕐 A che ora deve essere pubblicato l'ordine? Inserisci un numero tra 0 e 23 (UTC)." +invalid_hour: "⚠️ Ora non valida. Inserisci un numero tra 0 e 23." +schedule_confirm_summary: | + 📋 *Riepilogo ordine programmato* + + Tipo: ${type} + Importo: ${fiatAmount} ${fiatCode} + Metodo di pagamento: ${paymentMethod} + Giorni: ${days} + Ora: ${hour} diff --git a/locales/ko.yaml b/locales/ko.yaml index 03c9dafe..c9143209 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -722,3 +722,27 @@ community_enabled_by_admin: 관리자가 커뮤니티 ${communityName}을(를) community_already_disabled: 이 커뮤니티는 이미 비활성화되어 있습니다 community_already_enabled: 이 커뮤니티는 이미 활성화되어 있습니다 ambiguous_community_input: 해당 사용자 이름과 일치하는 커뮤니티가 여러 개 있습니다. 커뮤니티 ID를 사용해 주세요 + +scheduleorder_usage: "사용법: /scheduleorder <법정화폐 금액> <법정화폐 코드> <결제 방법> [프리미엄]" +cancelschedule_usage: "사용법: /cancelschedule " +schedule_not_found: "⚠️ 스케줄을 찾을 수 없거나 이미 비활성화되었습니다." +schedule_cancelled: "✅ 스케줄 ${scheduleId}이(가) 취소되었습니다. 더 이상 자동으로 주문이 게시되지 않습니다." +schedule_creation_cancelled: "❌ 스케줄 생성이 취소되었습니다." +schedule_created: "✅ 예약 주문이 생성되었습니다! ID: ${scheduleId}\n중지하려면 /cancelschedule ${scheduleId}을 사용하세요." +schedule_choose_days: "📅 어떤 요일에 주문을 게시할까요?" +sched_daily: 매일 +sched_weekdays: 월–금 +sched_weekend: 주말 +sched_custom: 직접 입력 +schedule_enter_custom_days: "✍️ 요일을 쉼표로 구분하여 입력하세요 (예: monday, wednesday, friday)" +invalid_days: "⚠️ 요일을 인식할 수 없습니다. 쉼표로 구분하여 입력해 주세요 (예: monday, wednesday, friday)." +schedule_enter_hour: "🕐 주문을 게시할 시간을 입력하세요. 0에서 23 사이의 숫자를 입력하세요 (UTC)." +invalid_hour: "⚠️ 유효하지 않은 시간입니다. 0에서 23 사이의 숫자를 입력하세요." +schedule_confirm_summary: | + 📋 *예약 주문 요약* + + 유형: ${type} + 금액: ${fiatAmount} ${fiatCode} + 결제 방법: ${paymentMethod} + 요일: ${days} + 시간: ${hour} diff --git a/locales/pt.yaml b/locales/pt.yaml index 991d31e6..3c25f18b 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -724,3 +724,27 @@ community_enabled_by_admin: Um administrador reativou sua comunidade ${community community_already_disabled: Esta comunidade já está desativada community_already_enabled: Esta comunidade já está ativada ambiguous_community_input: Várias comunidades correspondem a esse nome de usuário, por favor use o ID da comunidade + +scheduleorder_usage: "Uso: /scheduleorder [prêmio]" +cancelschedule_usage: "Uso: /cancelschedule " +schedule_not_found: "⚠️ Schedule não encontrado ou já inativo." +schedule_cancelled: "✅ Schedule ${scheduleId} cancelado. Não serão mais publicadas ordens automaticamente." +schedule_creation_cancelled: "❌ Criação do schedule cancelada." +schedule_created: "✅ Ordem programada criada! Seu ID é: ${scheduleId}\nUse /cancelschedule ${scheduleId} para pará-la." +schedule_choose_days: "📅 Em quais dias a ordem deve ser publicada?" +sched_daily: Todos os dias +sched_weekdays: Seg–Sex +sched_weekend: Fim de semana +sched_custom: Personalizado +schedule_enter_custom_days: "✍️ Insira os dias separados por vírgulas (ex: segunda, quarta, sexta)" +invalid_days: "⚠️ Não entendi esses dias. Por favor insira-os separados por vírgulas (ex: segunda, quarta, sexta)." +schedule_enter_hour: "🕐 Em que hora a ordem deve ser publicada? Insira um número entre 0 e 23 (UTC)." +invalid_hour: "⚠️ Hora inválida. Por favor insira um número entre 0 e 23." +schedule_confirm_summary: | + 📋 *Resumo da ordem programada* + + Tipo: ${type} + Valor: ${fiatAmount} ${fiatCode} + Método de pagamento: ${paymentMethod} + Dias: ${days} + Hora: ${hour} diff --git a/locales/ru.yaml b/locales/ru.yaml index 15849091..c0b0c67b 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -725,3 +725,27 @@ community_enabled_by_admin: Администратор повторно вклю community_already_disabled: Это сообщество уже отключено community_already_enabled: Это сообщество уже включено ambiguous_community_input: Несколько сообществ соответствуют этому имени пользователя, пожалуйста, используйте ID сообщества + +scheduleorder_usage: "Использование: /scheduleorder <сумма фиат> <код фиат> <способ оплаты> [премиум]" +cancelschedule_usage: "Использование: /cancelschedule " +schedule_not_found: "⚠️ Расписание не найдено или уже неактивно." +schedule_cancelled: "✅ Расписание ${scheduleId} отменено. Ордера больше не будут публиковаться автоматически." +schedule_creation_cancelled: "❌ Создание расписания отменено." +schedule_created: "✅ Плановый ордер создан! Ваш ID: ${scheduleId}\nИспользуйте /cancelschedule ${scheduleId} для остановки." +schedule_choose_days: "📅 В какие дни публиковать ордер?" +sched_daily: Каждый день +sched_weekdays: Пн–Пт +sched_weekend: Выходные +sched_custom: Настроить +schedule_enter_custom_days: "✍️ Введите дни через запятую (например: monday, wednesday, friday)" +invalid_days: "⚠️ Не удалось распознать дни. Введите их через запятую (например: monday, wednesday, friday)." +schedule_enter_hour: "🕐 В какой час публиковать ордер? Введите число от 0 до 23 (UTC)." +invalid_hour: "⚠️ Неверный час. Введите число от 0 до 23." +schedule_confirm_summary: | + 📋 *Сводка планового ордера* + + Тип: ${type} + Сумма: ${fiatAmount} ${fiatCode} + Способ оплаты: ${paymentMethod} + Дни: ${days} + Час: ${hour} diff --git a/locales/uk.yaml b/locales/uk.yaml index bd9a5707..eb49d294 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -721,3 +721,27 @@ community_enabled_by_admin: Адміністратор повторно увім community_already_disabled: Ця спільнота вже вимкнена community_already_enabled: Ця спільнота вже увімкнена ambiguous_community_input: Кілька спільнот відповідають цьому імені користувача, будь ласка, використовуйте ID спільноти + +scheduleorder_usage: "Використання: /scheduleorder <сума фіат> <код фіат> <спосіб оплати> [преміум]" +cancelschedule_usage: "Використання: /cancelschedule " +schedule_not_found: "⚠️ Розклад не знайдено або вже неактивний." +schedule_cancelled: "✅ Розклад ${scheduleId} скасовано. Ордери більше не будуть публікуватися автоматично." +schedule_creation_cancelled: "❌ Створення розкладу скасовано." +schedule_created: "✅ Плановий ордер створено! Ваш ID: ${scheduleId}\nВикористовуйте /cancelschedule ${scheduleId} для зупинки." +schedule_choose_days: "📅 У які дні публікувати ордер?" +sched_daily: Щодня +sched_weekdays: Пн–Пт +sched_weekend: Вихідні +sched_custom: Налаштувати +schedule_enter_custom_days: "✍️ Введіть дні через кому (наприклад: monday, wednesday, friday)" +invalid_days: "⚠️ Не вдалося розпізнати дні. Введіть їх через кому (наприклад: monday, wednesday, friday)." +schedule_enter_hour: "🕐 О якій годині публікувати ордер? Введіть число від 0 до 23 (UTC)." +invalid_hour: "⚠️ Недійсна година. Введіть число від 0 до 23." +schedule_confirm_summary: | + 📋 *Зведення планового ордера* + + Тип: ${type} + Сума: ${fiatAmount} ${fiatCode} + Спосіб оплати: ${paymentMethod} + Дні: ${days} + Година: ${hour} From 2c5659112cdb7da23b3ba1b0c4cc4628b3abb45f Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 29 Jun 2026 19:21:13 -0300 Subject: [PATCH 04/19] i18n: add schedule commands to /help text in all locales --- locales/de.yaml | 2 ++ locales/es.yaml | 2 ++ locales/fa.yaml | 6 ++++++ locales/fr.yaml | 2 ++ locales/it.yaml | 2 ++ locales/ko.yaml | 2 ++ locales/pt.yaml | 2 ++ locales/ru.yaml | 2 ++ locales/uk.yaml | 2 ++ 9 files changed, 22 insertions(+) diff --git a/locales/de.yaml b/locales/de.yaml index 6374c5f4..1b234155 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -261,6 +261,8 @@ help: | /blocklist - Liste Ihrer blockierten Benutzer /cancel <_order id_> - Einen Auftrag stornieren, die noch nicht angenommen wurde /cancelall - Storniere alle aufgegebenen und nicht angenommenen Aufträge + /scheduleorder <_kaufen|verkaufen_> <_sats_> <_Fiat-Betrag_> <_Fiat-Code_> <_Zahlungsmethode_> [_Aufschlag_] - Erstellt einen wiederkehrenden Auftrag, der automatisch neu veröffentlicht wird + /cancelschedule <_Schedule-ID_> - Stoppt die automatische Neuveröffentlichung eines geplanten Auftrags /terms - Zeigt die Allgemeinen Geschäftsbedingungen bei der Nutzung des Bots an Nostr: diff --git a/locales/es.yaml b/locales/es.yaml index 3be27d6d..a67ecdb8 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -262,6 +262,8 @@ help: | /blocklist - Listado de tus usuarios bloqueados /cancel <_id orden_> - Cancela una orden que no ha sido tomada /cancelall - Cancela todas las órdenes publicadas y que no han sido tomadas + /scheduleorder <_compra|venta_> <_sats_> <_monto fiat_> <_código fiat_> <_método de pago_> [_prima_] - Crea una orden recurrente que se republica automáticamente + /cancelschedule <_id del schedule_> - Detiene la republicación automática de una orden programada /terms - Muestra los términos y condiciones al usar el bot /privacy - Muestra la Política de Privacidad diff --git a/locales/fa.yaml b/locales/fa.yaml index 61f736d1..31767343 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -314,6 +314,12 @@ help: | /cancelall تمامی سفارش‌های منتشر شده و پذیرفته نشده را لغو می‌کند. + /scheduleorder <_buy|sell_> <_sats_> <_مبلغ فیات_> <_کد فیات_> <_روش پرداخت_> [_پریمیوم_> + یک سفارش تکرارشونده ایجاد می‌کند که به‌صورت خودکار بازنشر می‌شود. + + /cancelschedule <_شناسه زمان‌بندی_> + بازنشر خودکار سفارش زمان‌بندی‌شده را متوقف می‌کند. + /terms شرایط و ضوابط استفاده از ربات را نمایش می‌دهد. diff --git a/locales/fr.yaml b/locales/fr.yaml index 6e149611..2d22d029 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -263,6 +263,8 @@ help: | /blocklist - Liste de vos utilisateurs bloqués /cancel <_order id_> - Annule une commande qui n'a pas encore été prise /cancelall - Annule toutes les commandes validées et non prises + /scheduleorder <_acheter|vendre_> <_sats_> <_montant fiat_> <_code fiat_> <_méthode de paiement_> [_prime_] - Crée une commande récurrente qui se republie automatiquement + /cancelschedule <_id du schedule_> - Arrête la republication automatique d'une commande planifiée /terms - Affiche les termes et conditions lors de l'utilisation du bot Nostr: diff --git a/locales/it.yaml b/locales/it.yaml index d24eb0a9..ac9a3ba2 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -261,6 +261,8 @@ help: | /blocklist - Elenco degli utenti bloccati /cancel <_order id_> - Cancella un ordine che non è ancora stato accettato /cancelall - Cancella tutti gli ordini non ancora accettati + /scheduleorder <_comprare|vendere_> <_sats_> <_importo fiat_> <_codice fiat_> <_metodo di pagamento_> [_premio_] - Crea un ordine ricorrente che si ripubblica automaticamente + /cancelschedule <_id dello schedule_> - Ferma la ripubblicazione automatica di un ordine programmato /terms: mostra i termini e le condizioni quando si utilizza il bot Nostr: diff --git a/locales/ko.yaml b/locales/ko.yaml index c9143209..bc7213be 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -263,6 +263,8 @@ help: | /terms - 사용자 이용 약관 및 면책 조항을 표시합니다. /cancel <_주문 id_> - 아직 수락되지 않은 주문을 취소합니다. /cancelall - 등록되었지만 아직 수락되지 않은 모든 주문들을 취소합니다. + /scheduleorder <_buy|sell_> <_sats_> <_법정화폐 금액_> <_법정화폐 코드_> <_결제 방법_> [_프리미엄_] - 자동으로 재게시되는 반복 주문을 생성합니다 + /cancelschedule <_schedule id_> - 예약 주문의 자동 재게시를 중지합니다 Nostr: /setnpub <_노스터 npub_> - 노스터 공개키를 설정합니다. 이 명령어는 /settings 명령어 수행 중에만 사용 가능합니다. diff --git a/locales/pt.yaml b/locales/pt.yaml index 3c25f18b..570c99c3 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -262,6 +262,8 @@ help: | /blocklist - Lista de seus usuários bloqueados /cancel - Cancela um pedido que não foi feito /cancelall - Cancela todos os pedidos postados e não atendidos + /scheduleorder <_comprar|vender_> <_sats_> <_valor fiat_> <_código fiat_> <_método de pagamento_> [_prêmio_] - Cria um pedido recorrente que se republica automaticamente + /cancelschedule <_id do schedule_> - Para a republicação automática de um pedido programado /terms - Mostra os termos e condições ao usar o bot Nostr: diff --git a/locales/ru.yaml b/locales/ru.yaml index c0b0c67b..143db6c4 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -260,6 +260,8 @@ help: | /blocklist - Список заблокированных вами пользователей /cancel <_order id_> - Отменить заявку до ее исполнения /cancelall - Отменить все выставленные заявки + /scheduleorder <_buy|sell_> <_sats_> <_сумма фиат_> <_код фиат_> <_способ оплаты_> [_премиум_] - Создать повторяющуюся заявку с автопубликацией + /cancelschedule <_id расписания_> - Остановить автопубликацию запланированной заявки /terms - показать условия использования бота Nostr: diff --git a/locales/uk.yaml b/locales/uk.yaml index eb49d294..c3d92f90 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -260,6 +260,8 @@ help: | /blocklist - Список ваших заблокованих користувачів /cancel <_order id_> - Скасувати заявку до її виконання /cancelall - Скасувати всі виставлені заявки + /scheduleorder <_buy|sell_> <_sats_> <_сума фіат_> <_код фіат_> <_спосіб оплати_> [_преміум_] - Створити повторювану заявку з автопублікацією + /cancelschedule <_id розкладу_> - Зупинити автопублікацію запланованої заявки /terms - Показати умови використання бота Nostr: From 0704d188479c046d686be72e766a13be02be2df0 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 30 Jun 2026 16:01:48 -0300 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20parallel=20refresh,=20publish=20guard,=20ObjectId?= =?UTF-8?q?=20validation,=20English-only=20day=20input,=20localized=20prom?= =?UTF-8?q?pts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/modules/orders/takeOrder.ts | 16 ++++++++++++---- bot/modules/schedule/commands.ts | 3 ++- bot/modules/schedule/helpers.ts | 30 +++++------------------------- locales/de.yaml | 8 ++++---- locales/en.yaml | 4 ++-- locales/es.yaml | 8 ++++---- locales/fa.yaml | 4 ++-- locales/fr.yaml | 8 ++++---- locales/it.yaml | 8 ++++---- locales/ko.yaml | 4 ++-- locales/pt.yaml | 8 ++++---- locales/ru.yaml | 4 ++-- locales/uk.yaml | 4 ++-- 13 files changed, 49 insertions(+), 60 deletions(-) diff --git a/bot/modules/orders/takeOrder.ts b/bot/modules/orders/takeOrder.ts index a505771b..66b6f513 100644 --- a/bot/modules/orders/takeOrder.ts +++ b/bot/modules/orders/takeOrder.ts @@ -95,9 +95,9 @@ export const takebuy = async ( OrderEvents.orderUpdated(order); await deleteOrderFromChannel(order, bot.telegram); - await refreshScheduledOrder(order._id, bot); - + const refreshBuyPromise = refreshScheduledOrder(order._id, bot); await messages.beginTakeBuyMessage(ctx, bot, user, order); + await refreshBuyPromise; }); } catch (error) { logger.error(error); @@ -148,8 +148,9 @@ export const takesell = async ( OrderEvents.orderUpdated(order); // We delete the messages related to that order from the channel await deleteOrderFromChannel(order, bot.telegram); - await refreshScheduledOrder(order._id, bot); + const refreshSellPromise = refreshScheduledOrder(order._id, bot); await messages.beginTakeSellMessage(ctx, bot, user, order); + await refreshSellPromise; }); } catch (error) { logger.error(error); @@ -197,7 +198,14 @@ const refreshScheduledOrder = async ( schedule.type === 'buy' ? publishBuyOrderMessage : publishSellOrderMessage; - await publishFn(bot, creator, newOrder, i18n, false); + const published = await publishFn(bot, creator, newOrder, i18n, false); + + if (!published) { + logger.warning( + `refreshScheduledOrder: publish failed for schedule ${schedule._id}, skipping counter reset`, + ); + return; + } schedule.last_order_id = newOrder._id; schedule.republish_count = getRepublishCount(); diff --git a/bot/modules/schedule/commands.ts b/bot/modules/schedule/commands.ts index 0300ff5b..9f73b287 100644 --- a/bot/modules/schedule/commands.ts +++ b/bot/modules/schedule/commands.ts @@ -1,3 +1,4 @@ +import { Types } from 'mongoose'; import { CommunityContext } from '../community/communityContext'; import { ScheduledOrder } from '../../../models'; import { validateSellOrder, validateBuyOrder } from '../../validations'; @@ -56,7 +57,7 @@ export const cancelschedule = async (ctx: CommunityContext) => { const args = ctx.state?.command?.args || []; const scheduleId = args[0]; - if (!scheduleId) { + if (!scheduleId || !Types.ObjectId.isValid(scheduleId)) { return ctx.reply(ctx.i18n.t('cancelschedule_usage')); } diff --git a/bot/modules/schedule/helpers.ts b/bot/modules/schedule/helpers.ts index a3548da1..e73f3196 100644 --- a/bot/modules/schedule/helpers.ts +++ b/bot/modules/schedule/helpers.ts @@ -5,52 +5,32 @@ export const getRepublishCount = (): number => { return Number.isInteger(raw) && raw > 0 ? raw : REPUBLISH_DAYS_DEFAULT; }; -// Day names (Spanish, English, abbreviated) mapped to getUTCDay() values (0=Sun) +// English day names (full and abbreviated) mapped to getUTCDay() values (0=Sun..6=Sat) const DAY_ALIASES: Record = { - domingo: 0, sunday: 0, sun: 0, - dom: 0, - lunes: 1, monday: 1, mon: 1, - lun: 1, - martes: 2, tuesday: 2, tue: 2, - mar: 2, - miercoles: 3, wednesday: 3, wed: 3, - mie: 3, - jueves: 4, thursday: 4, thu: 4, - jue: 4, - viernes: 5, friday: 5, fri: 5, - vie: 5, - sabado: 6, saturday: 6, sat: 6, - sab: 6, }; -// Parses a comma/space separated list of day names into sorted weekday numbers. -// Tolerant to accents, casing and extra whitespace. Returns null if any token -// is not a recognized day. +// Parses a comma/space separated list of English day names into sorted UTC +// weekday values (0-6). Returns null if any token is unrecognized. export const parseCustomDays = (input: string): number[] | null => { - const parts = input - .toLowerCase() - .split(/[,\s]+/) - .filter(Boolean); + const parts = input.toLowerCase().split(/[,\s]+/).filter(Boolean); if (parts.length === 0) return null; const days = new Set(); for (const part of parts) { - // strip accents so "miércoles" -> "miercoles" - const normalized = part.normalize('NFD').replace(/[̀-ͯ]/g, ''); - const dayNum = DAY_ALIASES[normalized]; + const dayNum = DAY_ALIASES[part]; if (dayNum === undefined) return null; days.add(dayNum); } diff --git a/locales/de.yaml b/locales/de.yaml index 1b234155..13b6d4f7 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -261,7 +261,7 @@ help: | /blocklist - Liste Ihrer blockierten Benutzer /cancel <_order id_> - Einen Auftrag stornieren, die noch nicht angenommen wurde /cancelall - Storniere alle aufgegebenen und nicht angenommenen Aufträge - /scheduleorder <_kaufen|verkaufen_> <_sats_> <_Fiat-Betrag_> <_Fiat-Code_> <_Zahlungsmethode_> [_Aufschlag_] - Erstellt einen wiederkehrenden Auftrag, der automatisch neu veröffentlicht wird + /scheduleorder <_buy|sell_> <_sats_> <_Fiat-Betrag_> <_Fiat-Code_> <_Zahlungsmethode_> [_Aufschlag_] - Erstellt einen wiederkehrenden Auftrag, der automatisch neu veröffentlicht wird /cancelschedule <_Schedule-ID_> - Stoppt die automatische Neuveröffentlichung eines geplanten Auftrags /terms - Zeigt die Allgemeinen Geschäftsbedingungen bei der Nutzung des Bots an @@ -728,7 +728,7 @@ community_already_disabled: Diese Community ist bereits deaktiviert community_already_enabled: Diese Community ist bereits aktiviert ambiguous_community_input: Mehrere Communities stimmen mit diesem Benutzernamen überein, bitte verwende stattdessen die Community-ID -scheduleorder_usage: "Verwendung: /scheduleorder [Aufschlag]" +scheduleorder_usage: "Verwendung: /scheduleorder [Aufschlag]" cancelschedule_usage: "Verwendung: /cancelschedule " schedule_not_found: "⚠️ Schedule nicht gefunden oder bereits inaktiv." schedule_cancelled: "✅ Schedule ${scheduleId} abgebrochen. Es werden keine Bestellungen mehr automatisch veröffentlicht." @@ -739,8 +739,8 @@ sched_daily: Jeden Tag sched_weekdays: Mo–Fr sched_weekend: Wochenende sched_custom: Benutzerdefiniert -schedule_enter_custom_days: "✍️ Gib die Tage durch Kommas getrennt ein (z.B.: montag, mittwoch, freitag)" -invalid_days: "⚠️ Ich konnte diese Tage nicht verstehen. Bitte gib sie durch Kommas getrennt ein (z.B.: montag, mittwoch, freitag)." +schedule_enter_custom_days: "✍️ Gib die Tage auf Englisch durch Kommas getrennt ein (z.B.: sunday, wednesday, friday)" +invalid_days: "⚠️ Ungültige Tage. Gib englische Tagnamen durch Kommas getrennt ein (z.B.: sunday, wednesday, friday)." schedule_enter_hour: "🕐 Zu welcher Uhrzeit soll die Bestellung veröffentlicht werden? Gib eine Zahl zwischen 0 und 23 ein (UTC)." invalid_hour: "⚠️ Ungültige Stunde. Bitte gib eine Zahl zwischen 0 und 23 ein." schedule_confirm_summary: | diff --git a/locales/en.yaml b/locales/en.yaml index e912ea43..39709163 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -744,8 +744,8 @@ sched_daily: Every day sched_weekdays: Mon–Fri sched_weekend: Weekend sched_custom: Custom -schedule_enter_custom_days: "✍️ Enter the days separated by commas (e.g.: monday, wednesday, friday)" -invalid_days: "⚠️ I couldn't understand those days. Please enter them separated by commas (e.g.: monday, wednesday, friday)." +schedule_enter_custom_days: "✍️ Enter the days separated by commas (e.g.: sunday, wednesday, friday)" +invalid_days: "⚠️ Invalid days. Enter English day names separated by commas (e.g.: sunday, wednesday, friday)." schedule_enter_hour: "🕐 At what hour should the order be published? Enter a number between 0 and 23 (UTC)." invalid_hour: "⚠️ Invalid hour. Please enter a number between 0 and 23." schedule_confirm_summary: | diff --git a/locales/es.yaml b/locales/es.yaml index a67ecdb8..05828307 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -262,7 +262,7 @@ help: | /blocklist - Listado de tus usuarios bloqueados /cancel <_id orden_> - Cancela una orden que no ha sido tomada /cancelall - Cancela todas las órdenes publicadas y que no han sido tomadas - /scheduleorder <_compra|venta_> <_sats_> <_monto fiat_> <_código fiat_> <_método de pago_> [_prima_] - Crea una orden recurrente que se republica automáticamente + /scheduleorder <_buy|sell_> <_sats_> <_monto fiat_> <_código fiat_> <_método de pago_> [_prima_] - Crea una orden recurrente que se republica automáticamente /cancelschedule <_id del schedule_> - Detiene la republicación automática de una orden programada /terms - Muestra los términos y condiciones al usar el bot /privacy - Muestra la Política de Privacidad @@ -731,7 +731,7 @@ community_already_disabled: Esta comunidad ya está deshabilitada community_already_enabled: Esta comunidad ya está habilitada ambiguous_community_input: Varias comunidades coinciden con ese nombre de usuario, por favor usa el ID de la comunidad -scheduleorder_usage: "Uso: /scheduleorder [prima]" +scheduleorder_usage: "Uso: /scheduleorder [prima]" cancelschedule_usage: "Uso: /cancelschedule " schedule_not_found: "⚠️ Schedule no encontrado o ya inactivo." schedule_cancelled: "✅ Schedule ${scheduleId} cancelado. No se publicarán más órdenes automáticamente." @@ -742,8 +742,8 @@ sched_daily: Todos los días sched_weekdays: Lun–Vie sched_weekend: Fin de semana sched_custom: Personalizado -schedule_enter_custom_days: "✍️ Ingresa los días separados por comas (ej: lunes, miércoles, viernes)" -invalid_days: "⚠️ No pude entender esos días. Por favor ingrésalos separados por comas (ej: lunes, miércoles, viernes)." +schedule_enter_custom_days: "✍️ Ingresa los días separados por comas en inglés (ej: sunday, wednesday, friday)" +invalid_days: "⚠️ Días inválidos. Ingresá los nombres de los días en inglés separados por comas (ej: sunday, wednesday, friday)." schedule_enter_hour: "🕐 ¿A qué hora debe publicarse la orden? Ingresa un número entre 0 y 23 (UTC)." invalid_hour: "⚠️ Hora inválida. Por favor ingresa un número entre 0 y 23." schedule_confirm_summary: | diff --git a/locales/fa.yaml b/locales/fa.yaml index 31767343..e8d5bc01 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -842,8 +842,8 @@ sched_daily: هر روز sched_weekdays: دوشنبه–جمعه sched_weekend: آخر هفته sched_custom: سفارشی -schedule_enter_custom_days: "✍️ روزها را با کاما وارد کنید (مثال: monday, wednesday, friday)" -invalid_days: "⚠️ روزها را نتوانستم تشخیص دهم. لطفاً با کاما جدا کنید (مثال: monday, wednesday, friday)." +schedule_enter_custom_days: "✍️ روزها را به انگلیسی و با کاما وارد کنید (مثال: sunday, wednesday, friday)" +invalid_days: "⚠️ روزهای نامعتبر. نام روزها را به انگلیسی با کاما جدا کنید (مثال: sunday, wednesday, friday)." schedule_enter_hour: "🕐 سفارش در چه ساعتی منتشر شود؟ عددی بین ۰ تا ۲۳ وارد کنید (UTC)." invalid_hour: "⚠️ ساعت نامعتبر است. لطفاً عددی بین ۰ تا ۲۳ وارد کنید." schedule_confirm_summary: | diff --git a/locales/fr.yaml b/locales/fr.yaml index 2d22d029..e24debad 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -263,7 +263,7 @@ help: | /blocklist - Liste de vos utilisateurs bloqués /cancel <_order id_> - Annule une commande qui n'a pas encore été prise /cancelall - Annule toutes les commandes validées et non prises - /scheduleorder <_acheter|vendre_> <_sats_> <_montant fiat_> <_code fiat_> <_méthode de paiement_> [_prime_] - Crée une commande récurrente qui se republie automatiquement + /scheduleorder <_buy|sell_> <_sats_> <_montant fiat_> <_code fiat_> <_méthode de paiement_> [_prime_] - Crée une commande récurrente qui se republie automatiquement /cancelschedule <_id du schedule_> - Arrête la republication automatique d'une commande planifiée /terms - Affiche les termes et conditions lors de l'utilisation du bot @@ -727,7 +727,7 @@ community_already_disabled: Cette communauté est déjà désactivée community_already_enabled: Cette communauté est déjà activée ambiguous_community_input: Plusieurs communautés correspondent à ce nom d'utilisateur, veuillez utiliser l'ID de la communauté à la place -scheduleorder_usage: "Utilisation : /scheduleorder [prime]" +scheduleorder_usage: "Utilisation : /scheduleorder [prime]" cancelschedule_usage: "Utilisation : /cancelschedule " schedule_not_found: "⚠️ Schedule introuvable ou déjà inactif." schedule_cancelled: "✅ Schedule ${scheduleId} annulé. Aucune autre commande ne sera publiée automatiquement." @@ -738,8 +738,8 @@ sched_daily: Tous les jours sched_weekdays: Lun–Ven sched_weekend: Week-end sched_custom: Personnalisé -schedule_enter_custom_days: "✍️ Entrez les jours séparés par des virgules (ex : lundi, mercredi, vendredi)" -invalid_days: "⚠️ Je n'ai pas compris ces jours. Veuillez les entrer séparés par des virgules (ex : lundi, mercredi, vendredi)." +schedule_enter_custom_days: "✍️ Entrez les jours en anglais séparés par des virgules (ex : sunday, wednesday, friday)" +invalid_days: "⚠️ Jours invalides. Entrez les noms des jours en anglais séparés par des virgules (ex : sunday, wednesday, friday)." schedule_enter_hour: "🕐 À quelle heure l'ordre doit-il être publié ? Entrez un nombre entre 0 et 23 (UTC)." invalid_hour: "⚠️ Heure invalide. Veuillez entrer un nombre entre 0 et 23." schedule_confirm_summary: | diff --git a/locales/it.yaml b/locales/it.yaml index ac9a3ba2..67d4c547 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -261,7 +261,7 @@ help: | /blocklist - Elenco degli utenti bloccati /cancel <_order id_> - Cancella un ordine che non è ancora stato accettato /cancelall - Cancella tutti gli ordini non ancora accettati - /scheduleorder <_comprare|vendere_> <_sats_> <_importo fiat_> <_codice fiat_> <_metodo di pagamento_> [_premio_] - Crea un ordine ricorrente che si ripubblica automaticamente + /scheduleorder <_buy|sell_> <_sats_> <_importo fiat_> <_codice fiat_> <_metodo di pagamento_> [_premio_] - Crea un ordine ricorrente che si ripubblica automaticamente /cancelschedule <_id dello schedule_> - Ferma la ripubblicazione automatica di un ordine programmato /terms: mostra i termini e le condizioni quando si utilizza il bot @@ -725,7 +725,7 @@ community_already_disabled: Questa comunità è già disabilitata community_already_enabled: Questa comunità è già abilitata ambiguous_community_input: Più comunità corrispondono a quel nome utente, usa invece l'ID della comunità -scheduleorder_usage: "Utilizzo: /scheduleorder [premio]" +scheduleorder_usage: "Utilizzo: /scheduleorder [premio]" cancelschedule_usage: "Utilizzo: /cancelschedule " schedule_not_found: "⚠️ Schedule non trovato o già inattivo." schedule_cancelled: "✅ Schedule ${scheduleId} annullato. Non verranno più pubblicate offerte automaticamente." @@ -736,8 +736,8 @@ sched_daily: Ogni giorno sched_weekdays: Lun–Ven sched_weekend: Weekend sched_custom: Personalizzato -schedule_enter_custom_days: "✍️ Inserisci i giorni separati da virgole (es: lunedì, mercoledì, venerdì)" -invalid_days: "⚠️ Non ho capito quei giorni. Inseriscili separati da virgole (es: lunedì, mercoledì, venerdì)." +schedule_enter_custom_days: "✍️ Inserisci i giorni in inglese separati da virgole (es: sunday, wednesday, friday)" +invalid_days: "⚠️ Giorni non validi. Inserisci i nomi dei giorni in inglese separati da virgole (es: sunday, wednesday, friday)." schedule_enter_hour: "🕐 A che ora deve essere pubblicato l'ordine? Inserisci un numero tra 0 e 23 (UTC)." invalid_hour: "⚠️ Ora non valida. Inserisci un numero tra 0 e 23." schedule_confirm_summary: | diff --git a/locales/ko.yaml b/locales/ko.yaml index bc7213be..53d5d4b9 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -736,8 +736,8 @@ sched_daily: 매일 sched_weekdays: 월–금 sched_weekend: 주말 sched_custom: 직접 입력 -schedule_enter_custom_days: "✍️ 요일을 쉼표로 구분하여 입력하세요 (예: monday, wednesday, friday)" -invalid_days: "⚠️ 요일을 인식할 수 없습니다. 쉼표로 구분하여 입력해 주세요 (예: monday, wednesday, friday)." +schedule_enter_custom_days: "✍️ 요일을 영어로 쉼표 구분하여 입력하세요 (예: sunday, wednesday, friday)" +invalid_days: "⚠️ 유효하지 않은 요일입니다. 영어 요일 이름을 쉼표로 구분하여 입력하세요 (예: sunday, wednesday, friday)." schedule_enter_hour: "🕐 주문을 게시할 시간을 입력하세요. 0에서 23 사이의 숫자를 입력하세요 (UTC)." invalid_hour: "⚠️ 유효하지 않은 시간입니다. 0에서 23 사이의 숫자를 입력하세요." schedule_confirm_summary: | diff --git a/locales/pt.yaml b/locales/pt.yaml index 570c99c3..76d56070 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -262,7 +262,7 @@ help: | /blocklist - Lista de seus usuários bloqueados /cancel - Cancela um pedido que não foi feito /cancelall - Cancela todos os pedidos postados e não atendidos - /scheduleorder <_comprar|vender_> <_sats_> <_valor fiat_> <_código fiat_> <_método de pagamento_> [_prêmio_] - Cria um pedido recorrente que se republica automaticamente + /scheduleorder <_buy|sell_> <_sats_> <_valor fiat_> <_código fiat_> <_método de pagamento_> [_prêmio_] - Cria um pedido recorrente que se republica automaticamente /cancelschedule <_id do schedule_> - Para a republicação automática de um pedido programado /terms - Mostra os termos e condições ao usar o bot @@ -727,7 +727,7 @@ community_already_disabled: Esta comunidade já está desativada community_already_enabled: Esta comunidade já está ativada ambiguous_community_input: Várias comunidades correspondem a esse nome de usuário, por favor use o ID da comunidade -scheduleorder_usage: "Uso: /scheduleorder [prêmio]" +scheduleorder_usage: "Uso: /scheduleorder [prêmio]" cancelschedule_usage: "Uso: /cancelschedule " schedule_not_found: "⚠️ Schedule não encontrado ou já inativo." schedule_cancelled: "✅ Schedule ${scheduleId} cancelado. Não serão mais publicadas ordens automaticamente." @@ -738,8 +738,8 @@ sched_daily: Todos os dias sched_weekdays: Seg–Sex sched_weekend: Fim de semana sched_custom: Personalizado -schedule_enter_custom_days: "✍️ Insira os dias separados por vírgulas (ex: segunda, quarta, sexta)" -invalid_days: "⚠️ Não entendi esses dias. Por favor insira-os separados por vírgulas (ex: segunda, quarta, sexta)." +schedule_enter_custom_days: "✍️ Insira os dias em inglês separados por vírgulas (ex: sunday, wednesday, friday)" +invalid_days: "⚠️ Dias inválidos. Insira os nomes dos dias em inglês separados por vírgulas (ex: sunday, wednesday, friday)." schedule_enter_hour: "🕐 Em que hora a ordem deve ser publicada? Insira um número entre 0 e 23 (UTC)." invalid_hour: "⚠️ Hora inválida. Por favor insira um número entre 0 e 23." schedule_confirm_summary: | diff --git a/locales/ru.yaml b/locales/ru.yaml index 143db6c4..4d0f4485 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -739,8 +739,8 @@ sched_daily: Каждый день sched_weekdays: Пн–Пт sched_weekend: Выходные sched_custom: Настроить -schedule_enter_custom_days: "✍️ Введите дни через запятую (например: monday, wednesday, friday)" -invalid_days: "⚠️ Не удалось распознать дни. Введите их через запятую (например: monday, wednesday, friday)." +schedule_enter_custom_days: "✍️ Введите дни на английском через запятую (например: sunday, wednesday, friday)" +invalid_days: "⚠️ Неверные дни. Введите названия дней на английском через запятую (например: sunday, wednesday, friday)." schedule_enter_hour: "🕐 В какой час публиковать ордер? Введите число от 0 до 23 (UTC)." invalid_hour: "⚠️ Неверный час. Введите число от 0 до 23." schedule_confirm_summary: | diff --git a/locales/uk.yaml b/locales/uk.yaml index c3d92f90..c6436a61 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -735,8 +735,8 @@ sched_daily: Щодня sched_weekdays: Пн–Пт sched_weekend: Вихідні sched_custom: Налаштувати -schedule_enter_custom_days: "✍️ Введіть дні через кому (наприклад: monday, wednesday, friday)" -invalid_days: "⚠️ Не вдалося розпізнати дні. Введіть їх через кому (наприклад: monday, wednesday, friday)." +schedule_enter_custom_days: "✍️ Введіть дні англійською через кому (наприклад: sunday, wednesday, friday)" +invalid_days: "⚠️ Невірні дні. Введіть назви днів англійською через кому (наприклад: sunday, wednesday, friday)." schedule_enter_hour: "🕐 О якій годині публікувати ордер? Введіть число від 0 до 23 (UTC)." invalid_hour: "⚠️ Недійсна година. Введіть число від 0 до 23." schedule_confirm_summary: | From 73d0f424470ce47fb30b92715286a86596865a3f Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 30 Jun 2026 16:07:18 -0300 Subject: [PATCH 06/19] fix: remove _id: string override from IScheduledOrder to satisfy strict Mongoose Document typing --- models/scheduled_order.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/models/scheduled_order.ts b/models/scheduled_order.ts index 5f3e42c9..94a6c62c 100644 --- a/models/scheduled_order.ts +++ b/models/scheduled_order.ts @@ -1,7 +1,6 @@ import mongoose, { Document, Schema } from 'mongoose'; export interface IScheduledOrder extends Document { - _id: string; creator_id: string; type: string; amount: number; From 30d6ac6be2ba5530ec6aabd008fae33b67a8a23d Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 30 Jun 2026 16:24:46 -0300 Subject: [PATCH 07/19] style: run prettier format --- bot/modules/schedule/helpers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/modules/schedule/helpers.ts b/bot/modules/schedule/helpers.ts index e73f3196..ddbe9dde 100644 --- a/bot/modules/schedule/helpers.ts +++ b/bot/modules/schedule/helpers.ts @@ -26,7 +26,10 @@ const DAY_ALIASES: Record = { // Parses a comma/space separated list of English day names into sorted UTC // weekday values (0-6). Returns null if any token is unrecognized. export const parseCustomDays = (input: string): number[] | null => { - const parts = input.toLowerCase().split(/[,\s]+/).filter(Boolean); + const parts = input + .toLowerCase() + .split(/[,\s]+/) + .filter(Boolean); if (parts.length === 0) return null; const days = new Set(); for (const part of parts) { From 5fb65f3cfefe3b082f5a50946408b00fe5458e5a Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 30 Jun 2026 17:07:48 -0300 Subject: [PATCH 08/19] fix: translate order type in schedule summary and enable Markdown bold formatting --- bot/modules/schedule/messages.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/modules/schedule/messages.ts b/bot/modules/schedule/messages.ts index b7f90bcc..51ef427c 100644 --- a/bot/modules/schedule/messages.ts +++ b/bot/modules/schedule/messages.ts @@ -62,8 +62,9 @@ export const askScheduleConfirm = async ( }, ) => { const hour = String(summaryData.hour).padStart(2, '0'); + const typeKey = summaryData.type === 'buy' ? 'buying' : 'selling'; const summary = ctx.i18n.t('schedule_confirm_summary', { - type: summaryData.type.toUpperCase(), + type: ctx.i18n.t(typeKey), fiatAmount: summaryData.fiatAmount.join('-'), fiatCode: summaryData.fiatCode, paymentMethod: summaryData.paymentMethod, @@ -72,6 +73,7 @@ export const askScheduleConfirm = async ( }); await ctx.reply(summary, { + parse_mode: 'Markdown', reply_markup: { inline_keyboard: [ [ From 971a00a11ab4d7facb496a5afe921659ddb2329b Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 30 Jun 2026 17:14:50 -0300 Subject: [PATCH 09/19] fix: strict hour validation, publish guard in cron job, fix malformed fa.yaml bracket --- bot/modules/schedule/scenes.ts | 4 ++-- locales/fa.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/modules/schedule/scenes.ts b/bot/modules/schedule/scenes.ts index 20686a53..ba5c42d2 100644 --- a/bot/modules/schedule/scenes.ts +++ b/bot/modules/schedule/scenes.ts @@ -81,8 +81,8 @@ export const scheduleOrderWizard = new Scenes.WizardScene( try { const state = getState(ctx); const text = (ctx.message as any)?.text?.trim(); - const hour = parseInt(text); - if (isNaN(hour) || hour < 0 || hour > 23) { + const hour = parseInt(text, 10); + if (isNaN(hour) || hour < 0 || hour > 23 || String(hour) !== text) { await ctx.reply(ctx.i18n.t('invalid_hour')); return; // stay, reprompt } diff --git a/locales/fa.yaml b/locales/fa.yaml index e8d5bc01..fd2f5628 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -314,7 +314,7 @@ help: | /cancelall تمامی سفارش‌های منتشر شده و پذیرفته نشده را لغو می‌کند. - /scheduleorder <_buy|sell_> <_sats_> <_مبلغ فیات_> <_کد فیات_> <_روش پرداخت_> [_پریمیوم_> + /scheduleorder <_buy|sell_> <_sats_> <_مبلغ فیات_> <_کد فیات_> <_روش پرداخت_> [_پریمیوم_] یک سفارش تکرارشونده ایجاد می‌کند که به‌صورت خودکار بازنشر می‌شود. /cancelschedule <_شناسه زمان‌بندی_> From 67914634f7f7c014182c8446acf9ca1a9b9e5658 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 30 Jun 2026 17:25:30 -0300 Subject: [PATCH 10/19] fix: detect publish success via order side effects instead of void return value --- bot/modules/orders/takeOrder.ts | 7 +++++-- jobs/scheduled_orders.ts | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/bot/modules/orders/takeOrder.ts b/bot/modules/orders/takeOrder.ts index 66b6f513..b956f730 100644 --- a/bot/modules/orders/takeOrder.ts +++ b/bot/modules/orders/takeOrder.ts @@ -198,9 +198,12 @@ const refreshScheduledOrder = async ( schedule.type === 'buy' ? publishBuyOrderMessage : publishSellOrderMessage; - const published = await publishFn(bot, creator, newOrder, i18n, false); + await publishFn(bot, creator, newOrder, i18n, false); - if (!published) { + // publishFn swallows errors and returns void, so we detect success from the + // side effects it leaves on the order: a populated channel message id, and a + // status that was not closed because the channel was unreachable. + if (newOrder.status === 'CLOSED' || !newOrder.tg_channel_message1) { logger.warning( `refreshScheduledOrder: publish failed for schedule ${schedule._id}, skipping counter reset`, ); diff --git a/jobs/scheduled_orders.ts b/jobs/scheduled_orders.ts index daabea2d..c5a2625e 100644 --- a/jobs/scheduled_orders.ts +++ b/jobs/scheduled_orders.ts @@ -69,6 +69,17 @@ const publishScheduledOrders = async (bot: Telegraf) => { : publishSellOrderMessage; await publishFn(bot, user, order, i18n, false); + // publishFn swallows errors and returns void, so we detect success from + // the side effects it leaves on the order: a populated channel message + // id, and a status that was not closed because the channel was + // unreachable. Only advance the schedule on a confirmed publish. + if (order.status === 'CLOSED' || !order.tg_channel_message1) { + logger.warning( + `ScheduledOrder ${schedule._id}: publish failed, schedule not advanced`, + ); + continue; + } + schedule.last_order_id = order._id; schedule.republish_count -= 1; if (schedule.republish_count <= 0) schedule.active = false; From 6e18662ae32c83aaf7333524cc95aaced1d5fe27 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 30 Jun 2026 17:29:02 -0300 Subject: [PATCH 11/19] fix: guard day parsing against inherited Object.prototype keys (constructor, toString) --- bot/modules/schedule/helpers.ts | 4 +++- bot/modules/schedule/scenes.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/modules/schedule/helpers.ts b/bot/modules/schedule/helpers.ts index ddbe9dde..c02cc2e0 100644 --- a/bot/modules/schedule/helpers.ts +++ b/bot/modules/schedule/helpers.ts @@ -34,7 +34,9 @@ export const parseCustomDays = (input: string): number[] | null => { const days = new Set(); for (const part of parts) { const dayNum = DAY_ALIASES[part]; - if (dayNum === undefined) return null; + // Guard against inherited Object.prototype keys (e.g. "constructor", + // "toString") which would resolve to a function instead of undefined. + if (typeof dayNum !== 'number') return null; days.add(dayNum); } return [...days].sort((a, b) => a - b); diff --git a/bot/modules/schedule/scenes.ts b/bot/modules/schedule/scenes.ts index ba5c42d2..e32ce3bc 100644 --- a/bot/modules/schedule/scenes.ts +++ b/bot/modules/schedule/scenes.ts @@ -51,8 +51,9 @@ export const scheduleOrderWizard = new Scenes.WizardScene( await messages.askCustomDays(ctx); return; // stay on this step, waiting for the text } - if (PRESET_DAYS[data.replace('sched_', '')]) { - state.days = PRESET_DAYS[data.replace('sched_', '')]; + const presetKey = data.replace('sched_', ''); + if (Object.prototype.hasOwnProperty.call(PRESET_DAYS, presetKey)) { + state.days = PRESET_DAYS[presetKey]; await messages.askScheduleHour(ctx); return ctx.wizard.next(); } From b5e979219ebb3da6c3e02a7f18112673f5d2bf0d Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Wed, 1 Jul 2026 15:17:48 -0300 Subject: [PATCH 12/19] fix: reserve schedule cycle atomically before publish to avoid duplicate orders --- bot/modules/orders/takeOrder.ts | 23 ++++++++++------- jobs/scheduled_orders.ts | 44 ++++++++++++++++++++++++--------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/bot/modules/orders/takeOrder.ts b/bot/modules/orders/takeOrder.ts index b956f730..93bdd2a9 100644 --- a/bot/modules/orders/takeOrder.ts +++ b/bot/modules/orders/takeOrder.ts @@ -194,25 +194,30 @@ const refreshScheduledOrder = async ( if (!newOrder) return; + // Claim the refresh atomically BEFORE the external publish. Guarding on the + // old last_order_id moves the mold->order link to newOrder in a single step, + // so a concurrent take or a retry after a crash can't republish twice: once + // the link points at newOrder, this branch no longer matches the old id. + const claimed = await ScheduledOrder.findOneAndUpdate( + { _id: schedule._id, last_order_id: orderId, active: true }, + { last_order_id: newOrder._id, republish_count: getRepublishCount() }, + { new: true }, + ); + if (!claimed) return; // already refreshed by a concurrent take + const publishFn = schedule.type === 'buy' ? publishBuyOrderMessage : publishSellOrderMessage; await publishFn(bot, creator, newOrder, i18n, false); - // publishFn swallows errors and returns void, so we detect success from the - // side effects it leaves on the order: a populated channel message id, and a - // status that was not closed because the channel was unreachable. + // publishFn swallows errors and returns void; surface a failed publish for + // observability, but the refresh is already claimed either way. if (newOrder.status === 'CLOSED' || !newOrder.tg_channel_message1) { logger.warning( - `refreshScheduledOrder: publish failed for schedule ${schedule._id}, skipping counter reset`, + `refreshScheduledOrder: publish failed for schedule ${schedule._id} after claim`, ); - return; } - - schedule.last_order_id = newOrder._id; - schedule.republish_count = getRepublishCount(); - await schedule.save(); } catch (error) { logger.error(`refreshScheduledOrder error: ${String(error)}`); } diff --git a/jobs/scheduled_orders.ts b/jobs/scheduled_orders.ts index c5a2625e..b059a129 100644 --- a/jobs/scheduled_orders.ts +++ b/jobs/scheduled_orders.ts @@ -63,30 +63,52 @@ const publishScheduledOrders = async (bot: Telegraf) => { continue; } + // Reserve the cycle atomically BEFORE the external publish. The guard + // on republish_count acts as optimistic concurrency control: only one + // run can decrement from this exact value. If the process dies after + // reserving but before/while publishing, the schedule has already + // advanced, so the next tick will not republish the same cycle. We + // prefer losing one publication over posting a duplicate order. + const remaining = schedule.republish_count - 1; + const reserved = await ScheduledOrder.findOneAndUpdate( + { + _id: schedule._id, + active: true, + republish_count: schedule.republish_count, + }, + { + last_order_id: order._id, + republish_count: remaining, + active: remaining > 0, + }, + { new: true }, + ); + + if (!reserved) { + // Another run already claimed this cycle; drop the order we created. + logger.warning( + `ScheduledOrder ${schedule._id}: cycle already claimed, skipping publish`, + ); + continue; + } + const publishFn = schedule.type === 'buy' ? publishBuyOrderMessage : publishSellOrderMessage; await publishFn(bot, user, order, i18n, false); - // publishFn swallows errors and returns void, so we detect success from - // the side effects it leaves on the order: a populated channel message - // id, and a status that was not closed because the channel was - // unreachable. Only advance the schedule on a confirmed publish. + // publishFn swallows errors and returns void; surface a failed publish + // for observability, but the cycle is already reserved either way. if (order.status === 'CLOSED' || !order.tg_channel_message1) { logger.warning( - `ScheduledOrder ${schedule._id}: publish failed, schedule not advanced`, + `ScheduledOrder ${schedule._id}: publish failed after reservation`, ); continue; } - schedule.last_order_id = order._id; - schedule.republish_count -= 1; - if (schedule.republish_count <= 0) schedule.active = false; - await schedule.save(); - logger.info( - `ScheduledOrder ${schedule._id} published order ${order._id}, ${schedule.republish_count} cycles left`, + `ScheduledOrder ${schedule._id} published order ${order._id}, ${remaining} cycles left`, ); } catch (error) { logger.error(`publishScheduledOrders inner error: ${String(error)}`); From 8c17b63f2467853b3026cd1c0f763c0e4fa06009 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Sun, 5 Jul 2026 17:29:01 -0300 Subject: [PATCH 13/19] fix: remove scheduled order republish-on-take to avoid flooding the order book --- bot/modules/orders/takeOrder.ts | 75 +-------------------------------- 1 file changed, 1 insertion(+), 74 deletions(-) diff --git a/bot/modules/orders/takeOrder.ts b/bot/modules/orders/takeOrder.ts index 93bdd2a9..e861fd5d 100644 --- a/bot/modules/orders/takeOrder.ts +++ b/bot/modules/orders/takeOrder.ts @@ -1,10 +1,9 @@ import { logger } from '../../../logger'; -import { Block, Order, User, ScheduledOrder } from '../../../models'; +import { Block, Order, User } from '../../../models'; import { UserDocument } from '../../../models/user'; import { deleteOrderFromChannel, generateRandomImage, - getUserI18nContext, PerOrderIdMutex, } from '../../../util'; import * as messages from '../../messages'; @@ -17,8 +16,6 @@ import { validateTakeSellOrder, validateUserWaitingOrder, } from '../../validations'; -import { getRepublishCount } from '../schedule/helpers'; - const OrderEvents = require('../../modules/events/orders'); export const takeOrderActionValidation = async ( @@ -95,9 +92,7 @@ export const takebuy = async ( OrderEvents.orderUpdated(order); await deleteOrderFromChannel(order, bot.telegram); - const refreshBuyPromise = refreshScheduledOrder(order._id, bot); await messages.beginTakeBuyMessage(ctx, bot, user, order); - await refreshBuyPromise; }); } catch (error) { logger.error(error); @@ -148,81 +143,13 @@ export const takesell = async ( OrderEvents.orderUpdated(order); // We delete the messages related to that order from the channel await deleteOrderFromChannel(order, bot.telegram); - const refreshSellPromise = refreshScheduledOrder(order._id, bot); await messages.beginTakeSellMessage(ctx, bot, user, order); - await refreshSellPromise; }); } catch (error) { logger.error(error); } }; -// When a scheduled order is taken, reset its cycle counter and publish a -// fresh order from the mold so the trader's offer stays live on the channel. -const refreshScheduledOrder = async ( - orderId: string, - bot: HasTelegram, -): Promise => { - try { - const schedule = await ScheduledOrder.findOne({ - last_order_id: orderId, - active: true, - }); - if (!schedule) return; - - const creator = await User.findById(schedule.creator_id); - if (!creator) return; - - const i18n = await getUserI18nContext(creator); - - const ordersActions = require('../../ordersActions'); - const { - publishBuyOrderMessage, - publishSellOrderMessage, - } = require('../../messages'); - - const newOrder = await ordersActions.createOrder(i18n, bot, creator, { - type: schedule.type, - amount: schedule.amount, - fiatAmount: schedule.fiat_amount, - fiatCode: schedule.fiat_code, - paymentMethod: schedule.payment_method, - status: 'PENDING', - priceMargin: schedule.price_margin, - community_id: schedule.community_id, - }); - - if (!newOrder) return; - - // Claim the refresh atomically BEFORE the external publish. Guarding on the - // old last_order_id moves the mold->order link to newOrder in a single step, - // so a concurrent take or a retry after a crash can't republish twice: once - // the link points at newOrder, this branch no longer matches the old id. - const claimed = await ScheduledOrder.findOneAndUpdate( - { _id: schedule._id, last_order_id: orderId, active: true }, - { last_order_id: newOrder._id, republish_count: getRepublishCount() }, - { new: true }, - ); - if (!claimed) return; // already refreshed by a concurrent take - - const publishFn = - schedule.type === 'buy' - ? publishBuyOrderMessage - : publishSellOrderMessage; - await publishFn(bot, creator, newOrder, i18n, false); - - // publishFn swallows errors and returns void; surface a failed publish for - // observability, but the refresh is already claimed either way. - if (newOrder.status === 'CLOSED' || !newOrder.tg_channel_message1) { - logger.warning( - `refreshScheduledOrder: publish failed for schedule ${schedule._id} after claim`, - ); - } - } catch (error) { - logger.error(`refreshScheduledOrder error: ${String(error)}`); - } -}; - const checkBlockingStatus = async ( ctx: MainContext, user: UserDocument, From 9b5fe9d37b41e909176930ae649d256a983a1c86 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Sun, 5 Jul 2026 18:04:19 -0300 Subject: [PATCH 14/19] feat: add /listschedules and /cancelallschedules commands for scheduled orders --- bot/modules/schedule/commands.ts | 59 ++++++++++++++++++++++++++++++++ bot/modules/schedule/index.ts | 9 ++++- locales/de.yaml | 10 ++++++ locales/en.yaml | 10 ++++++ locales/es.yaml | 10 ++++++ locales/fa.yaml | 14 ++++++++ locales/fr.yaml | 10 ++++++ locales/it.yaml | 10 ++++++ locales/ko.yaml | 10 ++++++ locales/pt.yaml | 10 ++++++ locales/ru.yaml | 10 ++++++ locales/uk.yaml | 10 ++++++ 12 files changed, 171 insertions(+), 1 deletion(-) diff --git a/bot/modules/schedule/commands.ts b/bot/modules/schedule/commands.ts index 9f73b287..a7c563b6 100644 --- a/bot/modules/schedule/commands.ts +++ b/bot/modules/schedule/commands.ts @@ -5,6 +5,7 @@ import { validateSellOrder, validateBuyOrder } from '../../validations'; import { getCurrency } from '../../../util'; import { logger } from '../../../logger'; import { SCHEDULE_ORDER } from './scenes'; +import { formatDays } from './messages'; export const scheduleorder = async (ctx: CommunityContext) => { try { @@ -79,3 +80,61 @@ export const cancelschedule = async (ctx: CommunityContext) => { logger.error(error); } }; + +export const listschedules = async (ctx: CommunityContext) => { + try { + const user = ctx.user; + + const schedules = await ScheduledOrder.find({ + creator_id: user._id, + active: true, + }).sort({ created_at: 1 }); + + if (schedules.length === 0) { + return ctx.reply(ctx.i18n.t('listschedules_empty')); + } + + const items = schedules + .map(schedule => { + const hour = String(schedule.hour).padStart(2, '0'); + const typeKey = schedule.type === 'buy' ? 'buying' : 'selling'; + return ctx.i18n.t('schedule_list_item', { + scheduleId: String(schedule._id), + type: ctx.i18n.t(typeKey), + fiatAmount: schedule.fiat_amount.join('-'), + fiatCode: schedule.fiat_code, + paymentMethod: schedule.payment_method, + days: formatDays(schedule.days), + hour: `${hour}:00 UTC`, + }); + }) + .join('\n'); + + await ctx.reply(`${ctx.i18n.t('listschedules_header')}\n\n${items}`, { + parse_mode: 'Markdown', + }); + } catch (error) { + logger.error(error); + } +}; + +export const cancelallschedules = async (ctx: CommunityContext) => { + try { + const user = ctx.user; + + const result = await ScheduledOrder.updateMany( + { creator_id: user._id, active: true }, + { active: false }, + ); + + if (result.modifiedCount === 0) { + return ctx.reply(ctx.i18n.t('cancelallschedules_none')); + } + + await ctx.reply( + ctx.i18n.t('all_schedules_cancelled', { count: result.modifiedCount }), + ); + } catch (error) { + logger.error(error); + } +}; diff --git a/bot/modules/schedule/index.ts b/bot/modules/schedule/index.ts index 9281fa45..4f11d731 100644 --- a/bot/modules/schedule/index.ts +++ b/bot/modules/schedule/index.ts @@ -1,11 +1,18 @@ import { Telegraf } from 'telegraf'; import { userMiddleware } from '../../middleware/user'; import { CommunityContext } from '../community/communityContext'; -import { scheduleorder, cancelschedule } from './commands'; +import { + scheduleorder, + cancelschedule, + listschedules, + cancelallschedules, +} from './commands'; export { scheduleOrderWizard } from './scenes'; export const configure = (bot: Telegraf) => { bot.command('scheduleorder', userMiddleware, scheduleorder); bot.command('cancelschedule', userMiddleware, cancelschedule); + bot.command('listschedules', userMiddleware, listschedules); + bot.command('cancelallschedules', userMiddleware, cancelallschedules); }; diff --git a/locales/de.yaml b/locales/de.yaml index 13b6d4f7..d1d345b7 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -263,6 +263,8 @@ help: | /cancelall - Storniere alle aufgegebenen und nicht angenommenen Aufträge /scheduleorder <_buy|sell_> <_sats_> <_Fiat-Betrag_> <_Fiat-Code_> <_Zahlungsmethode_> [_Aufschlag_] - Erstellt einen wiederkehrenden Auftrag, der automatisch neu veröffentlicht wird /cancelschedule <_Schedule-ID_> - Stoppt die automatische Neuveröffentlichung eines geplanten Auftrags + /listschedules - Listet deine aktiven geplanten Aufträge mit ihren IDs auf + /cancelallschedules - Bricht alle deine geplanten Aufträge ab /terms - Zeigt die Allgemeinen Geschäftsbedingungen bei der Nutzung des Bots an Nostr: @@ -732,6 +734,14 @@ scheduleorder_usage: "Verwendung: /scheduleorder cancelschedule_usage: "Verwendung: /cancelschedule " schedule_not_found: "⚠️ Schedule nicht gefunden oder bereits inaktiv." schedule_cancelled: "✅ Schedule ${scheduleId} abgebrochen. Es werden keine Bestellungen mehr automatisch veröffentlicht." +listschedules_empty: "Du hast keine aktiven geplanten Aufträge." +listschedules_header: "📋 Deine geplanten Aufträge:" +schedule_list_item: | + 🆔 `${scheduleId}` + ${type} · ${fiatAmount} ${fiatCode} · ${paymentMethod} + 📅 ${days} · 🕐 ${hour} +cancelallschedules_none: "Du hast keine aktiven geplanten Aufträge zum Abbrechen." +all_schedules_cancelled: "✅ ${count} geplante(r) Auftrag/Aufträge abgebrochen. Es werden keine Bestellungen mehr automatisch veröffentlicht." schedule_creation_cancelled: "❌ Erstellung des Schedules abgebrochen." schedule_created: "✅ Geplante Bestellung erstellt! Deine ID ist: ${scheduleId}\nVerwende /cancelschedule ${scheduleId} zum Stoppen." schedule_choose_days: "📅 An welchen Tagen soll die Bestellung veröffentlicht werden?" diff --git a/locales/en.yaml b/locales/en.yaml index 39709163..d34017bc 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -267,6 +267,8 @@ help: | /cancelall - Cancel all posted and untaken orders /scheduleorder <_buy|sell_> <_sats_> <_fiat amount_> <_fiat code_> <_payment method_> [_premium_] - Creates a recurring order that auto-republishes /cancelschedule <_schedule id_> - Stops auto-republishing of a scheduled order + /listschedules - Lists your active scheduled orders with their IDs + /cancelallschedules - Cancels all your scheduled orders /terms - Shows the terms and conditions when using the bot Nostr: @@ -737,6 +739,14 @@ scheduleorder_usage: "Usage: /scheduleorder " schedule_not_found: "⚠️ Schedule not found or already inactive." schedule_cancelled: "✅ Schedule ${scheduleId} cancelled. No more orders will be auto-published." +listschedules_empty: "You have no active scheduled orders." +listschedules_header: "📋 Your scheduled orders:" +schedule_list_item: | + 🆔 `${scheduleId}` + ${type} · ${fiatAmount} ${fiatCode} · ${paymentMethod} + 📅 ${days} · 🕐 ${hour} +cancelallschedules_none: "You have no active scheduled orders to cancel." +all_schedules_cancelled: "✅ Cancelled ${count} scheduled order(s). No more orders will be auto-published." schedule_creation_cancelled: "❌ Schedule creation cancelled." schedule_created: "✅ Scheduled order created! Your ID is: ${scheduleId}\nUse /cancelschedule ${scheduleId} to stop it." schedule_choose_days: "📅 On which days should the order be published?" diff --git a/locales/es.yaml b/locales/es.yaml index 05828307..ef82a5e8 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -264,6 +264,8 @@ help: | /cancelall - Cancela todas las órdenes publicadas y que no han sido tomadas /scheduleorder <_buy|sell_> <_sats_> <_monto fiat_> <_código fiat_> <_método de pago_> [_prima_] - Crea una orden recurrente que se republica automáticamente /cancelschedule <_id del schedule_> - Detiene la republicación automática de una orden programada + /listschedules - Lista tus órdenes programadas activas con sus IDs + /cancelallschedules - Cancela todas tus órdenes programadas /terms - Muestra los términos y condiciones al usar el bot /privacy - Muestra la Política de Privacidad @@ -735,6 +737,14 @@ scheduleorder_usage: "Uso: /scheduleorder " schedule_not_found: "⚠️ Schedule no encontrado o ya inactivo." schedule_cancelled: "✅ Schedule ${scheduleId} cancelado. No se publicarán más órdenes automáticamente." +listschedules_empty: "No tenés órdenes programadas activas." +listschedules_header: "📋 Tus órdenes programadas:" +schedule_list_item: | + 🆔 `${scheduleId}` + ${type} · ${fiatAmount} ${fiatCode} · ${paymentMethod} + 📅 ${days} · 🕐 ${hour} +cancelallschedules_none: "No tenés órdenes programadas activas para cancelar." +all_schedules_cancelled: "✅ Se cancelaron ${count} orden(es) programada(s). No se publicarán más órdenes automáticamente." schedule_creation_cancelled: "❌ Creación de schedule cancelada." schedule_created: "✅ Orden programada creada. Tu ID es: ${scheduleId}\nUsa /cancelschedule ${scheduleId} para detenerla." schedule_choose_days: "📅 ¿En qué días debe publicarse la orden?" diff --git a/locales/fa.yaml b/locales/fa.yaml index fd2f5628..6bc586f3 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -320,6 +320,12 @@ help: | /cancelschedule <_شناسه زمان‌بندی_> بازنشر خودکار سفارش زمان‌بندی‌شده را متوقف می‌کند. + /listschedules + سفارش‌های زمان‌بندی‌شده فعال شما را همراه با شناسه‌هایشان فهرست می‌کند. + + /cancelallschedules + تمام سفارش‌های زمان‌بندی‌شده شما را لغو می‌کند. + /terms شرایط و ضوابط استفاده از ربات را نمایش می‌دهد. @@ -835,6 +841,14 @@ scheduleorder_usage: "راهنما: /scheduleorder <سات> <مبلغ cancelschedule_usage: "راهنما: /cancelschedule <شناسه زمان‌بندی>" schedule_not_found: "⚠️ زمان‌بندی پیدا نشد یا قبلاً غیرفعال شده است." schedule_cancelled: "✅ زمان‌بندی ${scheduleId} لغو شد. دیگر هیچ سفارشی به‌صورت خودکار منتشر نخواهد شد." +listschedules_empty: "شما هیچ سفارش زمان‌بندی‌شده فعالی ندارید." +listschedules_header: "📋 سفارش‌های زمان‌بندی‌شده شما:" +schedule_list_item: | + 🆔 `${scheduleId}` + ${type} · ${fiatAmount} ${fiatCode} · ${paymentMethod} + 📅 ${days} · 🕐 ${hour} +cancelallschedules_none: "شما هیچ سفارش زمان‌بندی‌شده فعالی برای لغو ندارید." +all_schedules_cancelled: "✅ ${count} سفارش زمان‌بندی‌شده لغو شد. دیگر هیچ سفارشی به‌صورت خودکار منتشر نخواهد شد." schedule_creation_cancelled: "❌ ایجاد زمان‌بندی لغو شد." schedule_created: "✅ سفارش زمان‌بندی‌شده ایجاد شد! شناسه شما: ${scheduleId}\nبرای توقف از /cancelschedule ${scheduleId} استفاده کنید." schedule_choose_days: "📅 در چه روزهایی سفارش منتشر شود؟" diff --git a/locales/fr.yaml b/locales/fr.yaml index e24debad..f6a650c6 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -265,6 +265,8 @@ help: | /cancelall - Annule toutes les commandes validées et non prises /scheduleorder <_buy|sell_> <_sats_> <_montant fiat_> <_code fiat_> <_méthode de paiement_> [_prime_] - Crée une commande récurrente qui se republie automatiquement /cancelschedule <_id du schedule_> - Arrête la republication automatique d'une commande planifiée + /listschedules - Liste vos commandes planifiées actives avec leurs identifiants + /cancelallschedules - Annule toutes vos commandes planifiées /terms - Affiche les termes et conditions lors de l'utilisation du bot Nostr: @@ -731,6 +733,14 @@ scheduleorder_usage: "Utilisation : /scheduleorder " schedule_not_found: "⚠️ Schedule introuvable ou déjà inactif." schedule_cancelled: "✅ Schedule ${scheduleId} annulé. Aucune autre commande ne sera publiée automatiquement." +listschedules_empty: "Vous n'avez aucune commande planifiée active." +listschedules_header: "📋 Vos commandes planifiées :" +schedule_list_item: | + 🆔 `${scheduleId}` + ${type} · ${fiatAmount} ${fiatCode} · ${paymentMethod} + 📅 ${days} · 🕐 ${hour} +cancelallschedules_none: "Vous n'avez aucune commande planifiée active à annuler." +all_schedules_cancelled: "✅ ${count} commande(s) planifiée(s) annulée(s). Aucune autre commande ne sera publiée automatiquement." schedule_creation_cancelled: "❌ Création du schedule annulée." schedule_created: "✅ Ordre planifié créé ! Votre ID est : ${scheduleId}\nUtilisez /cancelschedule ${scheduleId} pour l'arrêter." schedule_choose_days: "📅 Quels jours l'ordre doit-il être publié ?" diff --git a/locales/it.yaml b/locales/it.yaml index 67d4c547..df4527e4 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -263,6 +263,8 @@ help: | /cancelall - Cancella tutti gli ordini non ancora accettati /scheduleorder <_buy|sell_> <_sats_> <_importo fiat_> <_codice fiat_> <_metodo di pagamento_> [_premio_] - Crea un ordine ricorrente che si ripubblica automaticamente /cancelschedule <_id dello schedule_> - Ferma la ripubblicazione automatica di un ordine programmato + /listschedules - Elenca i tuoi ordini programmati attivi con i loro ID + /cancelallschedules - Annulla tutti i tuoi ordini programmati /terms: mostra i termini e le condizioni quando si utilizza il bot Nostr: @@ -729,6 +731,14 @@ scheduleorder_usage: "Utilizzo: /scheduleorder cancelschedule_usage: "Utilizzo: /cancelschedule " schedule_not_found: "⚠️ Schedule non trovato o già inattivo." schedule_cancelled: "✅ Schedule ${scheduleId} annullato. Non verranno più pubblicate offerte automaticamente." +listschedules_empty: "Non hai ordini programmati attivi." +listschedules_header: "📋 I tuoi ordini programmati:" +schedule_list_item: | + 🆔 `${scheduleId}` + ${type} · ${fiatAmount} ${fiatCode} · ${paymentMethod} + 📅 ${days} · 🕐 ${hour} +cancelallschedules_none: "Non hai ordini programmati attivi da annullare." +all_schedules_cancelled: "✅ Annullati ${count} ordine/i programmato/i. Non verranno più pubblicate offerte automaticamente." schedule_creation_cancelled: "❌ Creazione dello schedule annullata." schedule_created: "✅ Ordine programmato creato! Il tuo ID è: ${scheduleId}\nUsa /cancelschedule ${scheduleId} per fermarlo." schedule_choose_days: "📅 In quali giorni deve essere pubblicato l'ordine?" diff --git a/locales/ko.yaml b/locales/ko.yaml index 53d5d4b9..c0bef309 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -265,6 +265,8 @@ help: | /cancelall - 등록되었지만 아직 수락되지 않은 모든 주문들을 취소합니다. /scheduleorder <_buy|sell_> <_sats_> <_법정화폐 금액_> <_법정화폐 코드_> <_결제 방법_> [_프리미엄_] - 자동으로 재게시되는 반복 주문을 생성합니다 /cancelschedule <_schedule id_> - 예약 주문의 자동 재게시를 중지합니다 + /listschedules - 활성 예약 주문과 해당 ID를 나열합니다 + /cancelallschedules - 모든 예약 주문을 취소합니다 Nostr: /setnpub <_노스터 npub_> - 노스터 공개키를 설정합니다. 이 명령어는 /settings 명령어 수행 중에만 사용 가능합니다. @@ -729,6 +731,14 @@ scheduleorder_usage: "사용법: /scheduleorder <법정화폐 cancelschedule_usage: "사용법: /cancelschedule " schedule_not_found: "⚠️ 스케줄을 찾을 수 없거나 이미 비활성화되었습니다." schedule_cancelled: "✅ 스케줄 ${scheduleId}이(가) 취소되었습니다. 더 이상 자동으로 주문이 게시되지 않습니다." +listschedules_empty: "활성화된 예약 주문이 없습니다." +listschedules_header: "📋 예약 주문 목록:" +schedule_list_item: | + 🆔 `${scheduleId}` + ${type} · ${fiatAmount} ${fiatCode} · ${paymentMethod} + 📅 ${days} · 🕐 ${hour} +cancelallschedules_none: "취소할 활성 예약 주문이 없습니다." +all_schedules_cancelled: "✅ ${count}개의 예약 주문이 취소되었습니다. 더 이상 자동으로 주문이 게시되지 않습니다." schedule_creation_cancelled: "❌ 스케줄 생성이 취소되었습니다." schedule_created: "✅ 예약 주문이 생성되었습니다! ID: ${scheduleId}\n중지하려면 /cancelschedule ${scheduleId}을 사용하세요." schedule_choose_days: "📅 어떤 요일에 주문을 게시할까요?" diff --git a/locales/pt.yaml b/locales/pt.yaml index 76d56070..276d269d 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -264,6 +264,8 @@ help: | /cancelall - Cancela todos os pedidos postados e não atendidos /scheduleorder <_buy|sell_> <_sats_> <_valor fiat_> <_código fiat_> <_método de pagamento_> [_prêmio_] - Cria um pedido recorrente que se republica automaticamente /cancelschedule <_id do schedule_> - Para a republicação automática de um pedido programado + /listschedules - Lista seus pedidos programados ativos com seus IDs + /cancelallschedules - Cancela todos os seus pedidos programados /terms - Mostra os termos e condições ao usar o bot Nostr: @@ -731,6 +733,14 @@ scheduleorder_usage: "Uso: /scheduleorder " schedule_not_found: "⚠️ Schedule não encontrado ou já inativo." schedule_cancelled: "✅ Schedule ${scheduleId} cancelado. Não serão mais publicadas ordens automaticamente." +listschedules_empty: "Você não tem ordens programadas ativas." +listschedules_header: "📋 Suas ordens programadas:" +schedule_list_item: | + 🆔 `${scheduleId}` + ${type} · ${fiatAmount} ${fiatCode} · ${paymentMethod} + 📅 ${days} · 🕐 ${hour} +cancelallschedules_none: "Você não tem ordens programadas ativas para cancelar." +all_schedules_cancelled: "✅ Foram canceladas ${count} ordem(ns) programada(s). Não serão mais publicadas ordens automaticamente." schedule_creation_cancelled: "❌ Criação do schedule cancelada." schedule_created: "✅ Ordem programada criada! Seu ID é: ${scheduleId}\nUse /cancelschedule ${scheduleId} para pará-la." schedule_choose_days: "📅 Em quais dias a ordem deve ser publicada?" diff --git a/locales/ru.yaml b/locales/ru.yaml index 4d0f4485..114ea91f 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -262,6 +262,8 @@ help: | /cancelall - Отменить все выставленные заявки /scheduleorder <_buy|sell_> <_sats_> <_сумма фиат_> <_код фиат_> <_способ оплаты_> [_премиум_] - Создать повторяющуюся заявку с автопубликацией /cancelschedule <_id расписания_> - Остановить автопубликацию запланированной заявки + /listschedules - Показывает ваши активные запланированные заявки с их ID + /cancelallschedules - Отменяет все ваши запланированные заявки /terms - показать условия использования бота Nostr: @@ -732,6 +734,14 @@ scheduleorder_usage: "Использование: /scheduleorder " schedule_not_found: "⚠️ Расписание не найдено или уже неактивно." schedule_cancelled: "✅ Расписание ${scheduleId} отменено. Ордера больше не будут публиковаться автоматически." +listschedules_empty: "У вас нет активных запланированных заявок." +listschedules_header: "📋 Ваши запланированные заявки:" +schedule_list_item: | + 🆔 `${scheduleId}` + ${type} · ${fiatAmount} ${fiatCode} · ${paymentMethod} + 📅 ${days} · 🕐 ${hour} +cancelallschedules_none: "У вас нет активных запланированных заявок для отмены." +all_schedules_cancelled: "✅ Отменено запланированных заявок: ${count}. Ордера больше не будут публиковаться автоматически." schedule_creation_cancelled: "❌ Создание расписания отменено." schedule_created: "✅ Плановый ордер создан! Ваш ID: ${scheduleId}\nИспользуйте /cancelschedule ${scheduleId} для остановки." schedule_choose_days: "📅 В какие дни публиковать ордер?" diff --git a/locales/uk.yaml b/locales/uk.yaml index c6436a61..baaaf30f 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -262,6 +262,8 @@ help: | /cancelall - Скасувати всі виставлені заявки /scheduleorder <_buy|sell_> <_sats_> <_сума фіат_> <_код фіат_> <_спосіб оплати_> [_преміум_] - Створити повторювану заявку з автопублікацією /cancelschedule <_id розкладу_> - Зупинити автопублікацію запланованої заявки + /listschedules - Показує ваші активні заплановані заявки з їх ID + /cancelallschedules - Скасовує всі ваші заплановані заявки /terms - Показати умови використання бота Nostr: @@ -728,6 +730,14 @@ scheduleorder_usage: "Використання: /scheduleorder cancelschedule_usage: "Використання: /cancelschedule " schedule_not_found: "⚠️ Розклад не знайдено або вже неактивний." schedule_cancelled: "✅ Розклад ${scheduleId} скасовано. Ордери більше не будуть публікуватися автоматично." +listschedules_empty: "У вас немає активних запланованих заявок." +listschedules_header: "📋 Ваші заплановані заявки:" +schedule_list_item: | + 🆔 `${scheduleId}` + ${type} · ${fiatAmount} ${fiatCode} · ${paymentMethod} + 📅 ${days} · 🕐 ${hour} +cancelallschedules_none: "У вас немає активних запланованих заявок для скасування." +all_schedules_cancelled: "✅ Скасовано запланованих заявок: ${count}. Ордери більше не будуть публікуватися автоматично." schedule_creation_cancelled: "❌ Створення розкладу скасовано." schedule_created: "✅ Плановий ордер створено! Ваш ID: ${scheduleId}\nВикористовуйте /cancelschedule ${scheduleId} для зупинки." schedule_choose_days: "📅 У які дні публікувати ордер?" From 5d46b9df36b6b83eccf6230b3522170aec69a678 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Sun, 5 Jul 2026 18:13:02 -0300 Subject: [PATCH 15/19] feat: gate scheduled order creation behind anti-spam requirements --- .env-sample | 16 +++++ bot/modules/schedule/commands.ts | 9 +++ bot/modules/schedule/helpers.ts | 110 +++++++++++++++++++++++++++++++ locales/de.yaml | 6 ++ locales/en.yaml | 6 ++ locales/es.yaml | 6 ++ locales/fa.yaml | 6 ++ locales/fr.yaml | 6 ++ locales/it.yaml | 6 ++ locales/ko.yaml | 6 ++ locales/pt.yaml | 6 ++ locales/ru.yaml | 6 ++ locales/uk.yaml | 6 ++ 13 files changed, 195 insertions(+) diff --git a/.env-sample b/.env-sample index 225c9707..409ae8c8 100644 --- a/.env-sample +++ b/.env-sample @@ -119,3 +119,19 @@ MONITOR_URL='' MONITOR_AUTH_TOKEN='' # Bot name identifier sent with heartbeats (default: lnp2pBot) MONITOR_BOT_NAME='lnp2pBot' + +# Scheduled orders: number of publication cycles per schedule (default: 10) +REPUBLISH_ORDER_DAYS='10' +# Anti-spam requirements to create scheduled orders (all optional, defaults shown) +# Max active schedules a single user can hold at once +SCHEDULE_MAX_PER_USER='3' +# Minimum account age (days using the bot) required to schedule orders +SCHEDULE_MIN_ACCOUNT_AGE_DAYS='7' +# Minimum number of completed trades required to schedule orders +SCHEDULE_MIN_COMPLETED_ORDERS='5' +# Minimum traded volume in sats required to schedule orders (0 disables the check) +SCHEDULE_MIN_VOLUME='0' +# Minimum reputation (0-5) required to schedule orders (enforced only if the user has reviews) +SCHEDULE_MIN_RATING='4' +# Minimum order completion rate (SUCCESS / total created), 0-1 +SCHEDULE_MIN_COMPLETION_RATE='0.9' diff --git a/bot/modules/schedule/commands.ts b/bot/modules/schedule/commands.ts index a7c563b6..41a42100 100644 --- a/bot/modules/schedule/commands.ts +++ b/bot/modules/schedule/commands.ts @@ -6,6 +6,7 @@ import { getCurrency } from '../../../util'; import { logger } from '../../../logger'; import { SCHEDULE_ORDER } from './scenes'; import { formatDays } from './messages'; +import { checkScheduleRequirements } from './helpers'; export const scheduleorder = async (ctx: CommunityContext) => { try { @@ -38,6 +39,14 @@ export const scheduleorder = async (ctx: CommunityContext) => { return; } + // Gate creation behind anti-spam requirements (limit, seniority, completion + // rate, etc.) so only trusted makers can auto-publish orders. + const requirement = await checkScheduleRequirements(ctx.user); + if (!requirement.ok) { + await ctx.reply(ctx.i18n.t(requirement.messageKey!, requirement.params)); + return; + } + await ctx.scene.enter(SCHEDULE_ORDER, { user: ctx.user, scheduleType: orderType, diff --git a/bot/modules/schedule/helpers.ts b/bot/modules/schedule/helpers.ts index c02cc2e0..f221dc89 100644 --- a/bot/modules/schedule/helpers.ts +++ b/bot/modules/schedule/helpers.ts @@ -1,3 +1,6 @@ +import { Order, ScheduledOrder } from '../../../models'; +import { UserDocument } from '../../../models/user'; + const REPUBLISH_DAYS_DEFAULT = 10; export const getRepublishCount = (): number => { @@ -47,3 +50,110 @@ export const PRESET_DAYS: Record = { weekdays: [1, 2, 3, 4, 5], weekend: [0, 6], }; + +const envNumber = (raw: string | undefined, fallback: number): number => { + const value = Number(raw); + return Number.isFinite(value) ? value : fallback; +}; + +export interface ScheduleRequirementResult { + ok: boolean; + messageKey?: string; + params?: Record; +} + +// Gates the creation of scheduled orders to prevent spam and UX degradation: +// only trusted, active makers with a good completion rate should be allowed to +// publish orders automatically. All thresholds are configurable via env. +export const checkScheduleRequirements = async ( + user: UserDocument, +): Promise => { + const maxPerUser = envNumber(process.env.SCHEDULE_MAX_PER_USER, 3); + const minAgeDays = envNumber(process.env.SCHEDULE_MIN_ACCOUNT_AGE_DAYS, 7); + const minCompleted = envNumber(process.env.SCHEDULE_MIN_COMPLETED_ORDERS, 5); + const minVolume = envNumber(process.env.SCHEDULE_MIN_VOLUME, 0); + const minRating = envNumber(process.env.SCHEDULE_MIN_RATING, 4); + const minCompletionRate = envNumber( + process.env.SCHEDULE_MIN_COMPLETION_RATE, + 0.9, + ); + + // Hard limit on how many active schedules a user can hold at once. + const activeCount = await ScheduledOrder.countDocuments({ + creator_id: user._id, + active: true, + }); + if (activeCount >= maxPerUser) { + return { + ok: false, + messageKey: 'schedule_req_max_reached', + params: { max: maxPerUser }, + }; + } + + // Minimal account age (days using the bot). + const ageDays = + (Date.now() - new Date(user.created_at).getTime()) / (1000 * 60 * 60 * 24); + if (ageDays < minAgeDays) { + return { + ok: false, + messageKey: 'schedule_req_account_age', + params: { days: minAgeDays }, + }; + } + + // Minimum number of completed trades. + if (user.trades_completed < minCompleted) { + return { + ok: false, + messageKey: 'schedule_req_completed_orders', + params: { count: minCompleted }, + }; + } + + // Minimum traded volume (skipped when threshold is 0). + if (minVolume > 0 && user.volume_traded < minVolume) { + return { + ok: false, + messageKey: 'schedule_req_volume', + params: { volume: minVolume }, + }; + } + + // Minimum reputation, only enforced once the user has received reviews. + if ( + minRating > 0 && + user.total_reviews > 0 && + user.total_rating < minRating + ) { + return { + ok: false, + messageKey: 'schedule_req_rating', + params: { rating: minRating }, + }; + } + + // Completion rate: successfully finished orders over all orders the user has + // created. Low completion means auto-published orders would likely waste + // takers' time, so we require a high ratio. + const totalOrders = await Order.countDocuments({ creator_id: user._id }); + if (totalOrders > 0) { + const successOrders = await Order.countDocuments({ + creator_id: user._id, + status: 'SUCCESS', + }); + const rate = successOrders / totalOrders; + if (rate < minCompletionRate) { + return { + ok: false, + messageKey: 'schedule_req_completion_rate', + params: { + rate: Math.round(minCompletionRate * 100), + current: Math.round(rate * 100), + }, + }; + } + } + + return { ok: true }; +}; diff --git a/locales/de.yaml b/locales/de.yaml index d1d345b7..c39b7c57 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -742,6 +742,12 @@ schedule_list_item: | 📅 ${days} · 🕐 ${hour} cancelallschedules_none: "Du hast keine aktiven geplanten Aufträge zum Abbrechen." all_schedules_cancelled: "✅ ${count} geplante(r) Auftrag/Aufträge abgebrochen. Es werden keine Bestellungen mehr automatisch veröffentlicht." +schedule_req_max_reached: "⚠️ Du hast bereits ${max} aktive geplante Aufträge, das ist das erlaubte Maximum. Brich einen mit /cancelschedule ab, bevor du einen neuen erstellst." +schedule_req_account_age: "⚠️ Dein Konto muss mindestens ${days} Tage alt sein, um geplante Aufträge zu erstellen." +schedule_req_completed_orders: "⚠️ Du benötigst mindestens ${count} abgeschlossene Aufträge, um geplante Aufträge zu erstellen." +schedule_req_volume: "⚠️ Du benötigst ein gehandeltes Volumen von mindestens ${volume} Sats, um geplante Aufträge zu erstellen." +schedule_req_rating: "⚠️ Du benötigst eine Reputation von mindestens ${rating}, um geplante Aufträge zu erstellen." +schedule_req_completion_rate: "⚠️ Deine Auftrags-Abschlussquote (${current}%) liegt unter den erforderlichen ${rate}%. Schließe mehr deiner Aufträge ab, bevor du planst." schedule_creation_cancelled: "❌ Erstellung des Schedules abgebrochen." schedule_created: "✅ Geplante Bestellung erstellt! Deine ID ist: ${scheduleId}\nVerwende /cancelschedule ${scheduleId} zum Stoppen." schedule_choose_days: "📅 An welchen Tagen soll die Bestellung veröffentlicht werden?" diff --git a/locales/en.yaml b/locales/en.yaml index d34017bc..93e842bf 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -747,6 +747,12 @@ schedule_list_item: | 📅 ${days} · 🕐 ${hour} cancelallschedules_none: "You have no active scheduled orders to cancel." all_schedules_cancelled: "✅ Cancelled ${count} scheduled order(s). No more orders will be auto-published." +schedule_req_max_reached: "⚠️ You already have ${max} active scheduled orders, which is the maximum allowed. Cancel one with /cancelschedule before creating a new one." +schedule_req_account_age: "⚠️ Your account must be at least ${days} days old to create scheduled orders." +schedule_req_completed_orders: "⚠️ You need at least ${count} completed orders to create scheduled orders." +schedule_req_volume: "⚠️ You need a traded volume of at least ${volume} sats to create scheduled orders." +schedule_req_rating: "⚠️ You need a reputation of at least ${rating} to create scheduled orders." +schedule_req_completion_rate: "⚠️ Your order completion rate (${current}%) is below the required ${rate}%. Complete more of your orders before scheduling." schedule_creation_cancelled: "❌ Schedule creation cancelled." schedule_created: "✅ Scheduled order created! Your ID is: ${scheduleId}\nUse /cancelschedule ${scheduleId} to stop it." schedule_choose_days: "📅 On which days should the order be published?" diff --git a/locales/es.yaml b/locales/es.yaml index ef82a5e8..742bae48 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -745,6 +745,12 @@ schedule_list_item: | 📅 ${days} · 🕐 ${hour} cancelallschedules_none: "No tenés órdenes programadas activas para cancelar." all_schedules_cancelled: "✅ Se cancelaron ${count} orden(es) programada(s). No se publicarán más órdenes automáticamente." +schedule_req_max_reached: "⚠️ Ya tenés ${max} órdenes programadas activas, que es el máximo permitido. Cancelá una con /cancelschedule antes de crear una nueva." +schedule_req_account_age: "⚠️ Tu cuenta debe tener al menos ${days} días de antigüedad para crear órdenes programadas." +schedule_req_completed_orders: "⚠️ Necesitás al menos ${count} órdenes completadas para crear órdenes programadas." +schedule_req_volume: "⚠️ Necesitás un volumen operado de al menos ${volume} sats para crear órdenes programadas." +schedule_req_rating: "⚠️ Necesitás una reputación de al menos ${rating} para crear órdenes programadas." +schedule_req_completion_rate: "⚠️ Tu tasa de completitud de órdenes (${current}%) está por debajo del ${rate}% requerido. Completá más de tus órdenes antes de programar." schedule_creation_cancelled: "❌ Creación de schedule cancelada." schedule_created: "✅ Orden programada creada. Tu ID es: ${scheduleId}\nUsa /cancelschedule ${scheduleId} para detenerla." schedule_choose_days: "📅 ¿En qué días debe publicarse la orden?" diff --git a/locales/fa.yaml b/locales/fa.yaml index 6bc586f3..c51f1855 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -849,6 +849,12 @@ schedule_list_item: | 📅 ${days} · 🕐 ${hour} cancelallschedules_none: "شما هیچ سفارش زمان‌بندی‌شده فعالی برای لغو ندارید." all_schedules_cancelled: "✅ ${count} سفارش زمان‌بندی‌شده لغو شد. دیگر هیچ سفارشی به‌صورت خودکار منتشر نخواهد شد." +schedule_req_max_reached: "⚠️ شما در حال حاضر ${max} سفارش زمان‌بندی‌شده فعال دارید که حداکثر مجاز است. پیش از ساختن سفارش جدید، یکی را با /cancelschedule لغو کنید." +schedule_req_account_age: "⚠️ برای ساختن سفارش‌های زمان‌بندی‌شده، حساب شما باید حداقل ${days} روز قدمت داشته باشد." +schedule_req_completed_orders: "⚠️ برای ساختن سفارش‌های زمان‌بندی‌شده به حداقل ${count} سفارش تکمیل‌شده نیاز دارید." +schedule_req_volume: "⚠️ برای ساختن سفارش‌های زمان‌بندی‌شده به حجم معاملاتی حداقل ${volume} ساتوشی نیاز دارید." +schedule_req_rating: "⚠️ برای ساختن سفارش‌های زمان‌بندی‌شده به اعتبار حداقل ${rating} نیاز دارید." +schedule_req_completion_rate: "⚠️ نرخ تکمیل سفارش‌های شما (${current}%) کمتر از ${rate}% موردنیاز است. پیش از زمان‌بندی، سفارش‌های بیشتری را تکمیل کنید." schedule_creation_cancelled: "❌ ایجاد زمان‌بندی لغو شد." schedule_created: "✅ سفارش زمان‌بندی‌شده ایجاد شد! شناسه شما: ${scheduleId}\nبرای توقف از /cancelschedule ${scheduleId} استفاده کنید." schedule_choose_days: "📅 در چه روزهایی سفارش منتشر شود؟" diff --git a/locales/fr.yaml b/locales/fr.yaml index f6a650c6..43006337 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -741,6 +741,12 @@ schedule_list_item: | 📅 ${days} · 🕐 ${hour} cancelallschedules_none: "Vous n'avez aucune commande planifiée active à annuler." all_schedules_cancelled: "✅ ${count} commande(s) planifiée(s) annulée(s). Aucune autre commande ne sera publiée automatiquement." +schedule_req_max_reached: "⚠️ Vous avez déjà ${max} commandes planifiées actives, ce qui est le maximum autorisé. Annulez-en une avec /cancelschedule avant d'en créer une nouvelle." +schedule_req_account_age: "⚠️ Votre compte doit exister depuis au moins ${days} jours pour créer des commandes planifiées." +schedule_req_completed_orders: "⚠️ Vous devez avoir au moins ${count} commandes terminées pour créer des commandes planifiées." +schedule_req_volume: "⚠️ Vous devez avoir un volume échangé d'au moins ${volume} sats pour créer des commandes planifiées." +schedule_req_rating: "⚠️ Vous devez avoir une réputation d'au moins ${rating} pour créer des commandes planifiées." +schedule_req_completion_rate: "⚠️ Votre taux de complétion des commandes (${current}%) est inférieur aux ${rate}% requis. Terminez davantage de vos commandes avant de planifier." schedule_creation_cancelled: "❌ Création du schedule annulée." schedule_created: "✅ Ordre planifié créé ! Votre ID est : ${scheduleId}\nUtilisez /cancelschedule ${scheduleId} pour l'arrêter." schedule_choose_days: "📅 Quels jours l'ordre doit-il être publié ?" diff --git a/locales/it.yaml b/locales/it.yaml index df4527e4..584497bf 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -739,6 +739,12 @@ schedule_list_item: | 📅 ${days} · 🕐 ${hour} cancelallschedules_none: "Non hai ordini programmati attivi da annullare." all_schedules_cancelled: "✅ Annullati ${count} ordine/i programmato/i. Non verranno più pubblicate offerte automaticamente." +schedule_req_max_reached: "⚠️ Hai già ${max} ordini programmati attivi, che è il massimo consentito. Annullane uno con /cancelschedule prima di crearne uno nuovo." +schedule_req_account_age: "⚠️ Il tuo account deve avere almeno ${days} giorni per creare ordini programmati." +schedule_req_completed_orders: "⚠️ Ti servono almeno ${count} ordini completati per creare ordini programmati." +schedule_req_volume: "⚠️ Ti serve un volume scambiato di almeno ${volume} sats per creare ordini programmati." +schedule_req_rating: "⚠️ Ti serve una reputazione di almeno ${rating} per creare ordini programmati." +schedule_req_completion_rate: "⚠️ Il tuo tasso di completamento degli ordini (${current}%) è inferiore al ${rate}% richiesto. Completa più dei tuoi ordini prima di programmare." schedule_creation_cancelled: "❌ Creazione dello schedule annullata." schedule_created: "✅ Ordine programmato creato! Il tuo ID è: ${scheduleId}\nUsa /cancelschedule ${scheduleId} per fermarlo." schedule_choose_days: "📅 In quali giorni deve essere pubblicato l'ordine?" diff --git a/locales/ko.yaml b/locales/ko.yaml index c0bef309..2d6b49db 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -739,6 +739,12 @@ schedule_list_item: | 📅 ${days} · 🕐 ${hour} cancelallschedules_none: "취소할 활성 예약 주문이 없습니다." all_schedules_cancelled: "✅ ${count}개의 예약 주문이 취소되었습니다. 더 이상 자동으로 주문이 게시되지 않습니다." +schedule_req_max_reached: "⚠️ 이미 활성 예약 주문이 ${max}개 있으며, 이는 최대 허용 수입니다. 새로 만들기 전에 /cancelschedule 로 하나를 취소하세요." +schedule_req_account_age: "⚠️ 예약 주문을 생성하려면 계정이 최소 ${days}일 이상이어야 합니다." +schedule_req_completed_orders: "⚠️ 예약 주문을 생성하려면 완료된 주문이 최소 ${count}건 필요합니다." +schedule_req_volume: "⚠️ 예약 주문을 생성하려면 최소 ${volume} sats의 거래량이 필요합니다." +schedule_req_rating: "⚠️ 예약 주문을 생성하려면 평판이 최소 ${rating} 이상이어야 합니다." +schedule_req_completion_rate: "⚠️ 주문 완료율(${current}%)이 요구되는 ${rate}%보다 낮습니다. 예약하기 전에 더 많은 주문을 완료하세요." schedule_creation_cancelled: "❌ 스케줄 생성이 취소되었습니다." schedule_created: "✅ 예약 주문이 생성되었습니다! ID: ${scheduleId}\n중지하려면 /cancelschedule ${scheduleId}을 사용하세요." schedule_choose_days: "📅 어떤 요일에 주문을 게시할까요?" diff --git a/locales/pt.yaml b/locales/pt.yaml index 276d269d..2bd7e948 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -741,6 +741,12 @@ schedule_list_item: | 📅 ${days} · 🕐 ${hour} cancelallschedules_none: "Você não tem ordens programadas ativas para cancelar." all_schedules_cancelled: "✅ Foram canceladas ${count} ordem(ns) programada(s). Não serão mais publicadas ordens automaticamente." +schedule_req_max_reached: "⚠️ Você já tem ${max} ordens programadas ativas, que é o máximo permitido. Cancele uma com /cancelschedule antes de criar uma nova." +schedule_req_account_age: "⚠️ Sua conta deve ter pelo menos ${days} dias para criar ordens programadas." +schedule_req_completed_orders: "⚠️ Você precisa de pelo menos ${count} ordens concluídas para criar ordens programadas." +schedule_req_volume: "⚠️ Você precisa de um volume negociado de pelo menos ${volume} sats para criar ordens programadas." +schedule_req_rating: "⚠️ Você precisa de uma reputação de pelo menos ${rating} para criar ordens programadas." +schedule_req_completion_rate: "⚠️ Sua taxa de conclusão de ordens (${current}%) está abaixo dos ${rate}% exigidos. Conclua mais das suas ordens antes de programar." schedule_creation_cancelled: "❌ Criação do schedule cancelada." schedule_created: "✅ Ordem programada criada! Seu ID é: ${scheduleId}\nUse /cancelschedule ${scheduleId} para pará-la." schedule_choose_days: "📅 Em quais dias a ordem deve ser publicada?" diff --git a/locales/ru.yaml b/locales/ru.yaml index 114ea91f..5a7b627b 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -742,6 +742,12 @@ schedule_list_item: | 📅 ${days} · 🕐 ${hour} cancelallschedules_none: "У вас нет активных запланированных заявок для отмены." all_schedules_cancelled: "✅ Отменено запланированных заявок: ${count}. Ордера больше не будут публиковаться автоматически." +schedule_req_max_reached: "⚠️ У вас уже есть ${max} активных запланированных заявок — это максимум. Отмените одну командой /cancelschedule, прежде чем создавать новую." +schedule_req_account_age: "⚠️ Вашему аккаунту должно быть не менее ${days} дней, чтобы создавать запланированные заявки." +schedule_req_completed_orders: "⚠️ Вам нужно не менее ${count} завершённых ордеров, чтобы создавать запланированные заявки." +schedule_req_volume: "⚠️ Вам нужен торговый объём не менее ${volume} сатоши, чтобы создавать запланированные заявки." +schedule_req_rating: "⚠️ Вам нужна репутация не менее ${rating}, чтобы создавать запланированные заявки." +schedule_req_completion_rate: "⚠️ Ваш показатель завершения ордеров (${current}%) ниже требуемых ${rate}%. Завершите больше своих ордеров, прежде чем планировать." schedule_creation_cancelled: "❌ Создание расписания отменено." schedule_created: "✅ Плановый ордер создан! Ваш ID: ${scheduleId}\nИспользуйте /cancelschedule ${scheduleId} для остановки." schedule_choose_days: "📅 В какие дни публиковать ордер?" diff --git a/locales/uk.yaml b/locales/uk.yaml index baaaf30f..d2327b96 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -738,6 +738,12 @@ schedule_list_item: | 📅 ${days} · 🕐 ${hour} cancelallschedules_none: "У вас немає активних запланованих заявок для скасування." all_schedules_cancelled: "✅ Скасовано запланованих заявок: ${count}. Ордери більше не будуть публікуватися автоматично." +schedule_req_max_reached: "⚠️ У вас уже є ${max} активних запланованих заявок — це максимум. Скасуйте одну командою /cancelschedule, перш ніж створювати нову." +schedule_req_account_age: "⚠️ Вашому акаунту має бути щонайменше ${days} днів, щоб створювати заплановані заявки." +schedule_req_completed_orders: "⚠️ Вам потрібно щонайменше ${count} завершених ордерів, щоб створювати заплановані заявки." +schedule_req_volume: "⚠️ Вам потрібен торговий обсяг щонайменше ${volume} сатоші, щоб створювати заплановані заявки." +schedule_req_rating: "⚠️ Вам потрібна репутація щонайменше ${rating}, щоб створювати заплановані заявки." +schedule_req_completion_rate: "⚠️ Ваш показник завершення ордерів (${current}%) нижчий за необхідні ${rate}%. Завершіть більше своїх ордерів, перш ніж планувати." schedule_creation_cancelled: "❌ Створення розкладу скасовано." schedule_created: "✅ Плановий ордер створено! Ваш ID: ${scheduleId}\nВикористовуйте /cancelschedule ${scheduleId} для зупинки." schedule_choose_days: "📅 У які дні публікувати ордер?" From 0ccbbebcdc3442da7a9fb8b025367d4ee879f4ec Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 6 Jul 2026 00:05:27 +0100 Subject: [PATCH 16/19] fix: base schedule completion rate on taken orders only --- bot/modules/schedule/helpers.ts | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/bot/modules/schedule/helpers.ts b/bot/modules/schedule/helpers.ts index f221dc89..b3bb4acc 100644 --- a/bot/modules/schedule/helpers.ts +++ b/bot/modules/schedule/helpers.ts @@ -45,6 +45,22 @@ export const parseCustomDays = (input: string): number[] | null => { return [...days].sort((a, b) => a - b); }; +// Minutes from now until the next time the scheduler will publish an order for +// the given UTC weekdays (0=Sun..6=Sat) and hour (0-23). The job runs hourly on +// the hour, so publication happens at minute 0 of the matching hour. +export const minutesUntilNextRun = (days: number[], hour: number): number => { + const now = new Date(); + for (let offset = 0; offset < 8; offset++) { + const candidate = new Date(now); + candidate.setUTCDate(now.getUTCDate() + offset); + candidate.setUTCHours(hour, 0, 0, 0); + if (days.includes(candidate.getUTCDay()) && candidate > now) { + return Math.round((candidate.getTime() - now.getTime()) / 60000); + } + } + return 0; +}; + export const PRESET_DAYS: Record = { daily: [0, 1, 2, 3, 4, 5, 6], weekdays: [1, 2, 3, 4, 5], @@ -133,16 +149,21 @@ export const checkScheduleRequirements = async ( }; } - // Completion rate: successfully finished orders over all orders the user has - // created. Low completion means auto-published orders would likely waste - // takers' time, so we require a high ratio. - const totalOrders = await Order.countDocuments({ creator_id: user._id }); - if (totalOrders > 0) { + // Completion rate: successfully finished orders over the orders that were + // actually taken (taken_at set). Orders that expired in PENDING without a + // taker are excluded (they never wasted anyone's time), so this measures how + // often the maker follows through once a counterparty commits — which is what + // matters for auto-published orders. + const takenOrders = await Order.countDocuments({ + creator_id: user._id, + taken_at: { $ne: null }, + }); + if (takenOrders > 0) { const successOrders = await Order.countDocuments({ creator_id: user._id, status: 'SUCCESS', }); - const rate = successOrders / totalOrders; + const rate = successOrders / takenOrders; if (rate < minCompletionRate) { return { ok: false, From 5c21fc00c39edecac26665116a6c64d0b442f5a6 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 6 Jul 2026 00:05:30 +0100 Subject: [PATCH 17/19] feat: log minutes until next publication when a schedule is created --- bot/modules/schedule/scenes.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/modules/schedule/scenes.ts b/bot/modules/schedule/scenes.ts index e32ce3bc..6312e4d4 100644 --- a/bot/modules/schedule/scenes.ts +++ b/bot/modules/schedule/scenes.ts @@ -4,7 +4,12 @@ import { ScheduledOrder } from '../../../models'; import { CommunityContext } from '../community/communityContext'; import { UserDocument } from '../../../models/user'; import * as messages from './messages'; -import { getRepublishCount, parseCustomDays, PRESET_DAYS } from './helpers'; +import { + getRepublishCount, + minutesUntilNextRun, + parseCustomDays, + PRESET_DAYS, +} from './helpers'; export const SCHEDULE_ORDER = 'SCHEDULE_ORDER_WIZARD'; @@ -137,6 +142,11 @@ export const scheduleOrderWizard = new Scenes.WizardScene( active: true, }); + const minutesToPublish = minutesUntilNextRun(state.days, state.hour); + logger.info( + `ScheduledOrder ${schedule._id} created, next publication in ${minutesToPublish} minutes`, + ); + await ctx.reply( ctx.i18n.t('schedule_created', { scheduleId: schedule._id }), ); From d957c15ff542886c0b182306fc9b01439d2245bd Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 6 Jul 2026 00:12:28 +0100 Subject: [PATCH 18/19] feat: remove schedules of dormant makers who leave taken orders uncompleted --- .env-sample | 4 +++- bot/modules/schedule/helpers.ts | 25 ++++++++++++++++++++++++ jobs/scheduled_orders.ts | 34 +++++++++++++++++++++++++++++++++ locales/de.yaml | 1 + locales/en.yaml | 1 + locales/es.yaml | 1 + locales/fa.yaml | 1 + locales/fr.yaml | 1 + locales/it.yaml | 1 + locales/ko.yaml | 1 + locales/pt.yaml | 1 + locales/ru.yaml | 1 + locales/uk.yaml | 1 + 13 files changed, 72 insertions(+), 1 deletion(-) diff --git a/.env-sample b/.env-sample index 409ae8c8..31f10540 100644 --- a/.env-sample +++ b/.env-sample @@ -133,5 +133,7 @@ SCHEDULE_MIN_COMPLETED_ORDERS='5' SCHEDULE_MIN_VOLUME='0' # Minimum reputation (0-5) required to schedule orders (enforced only if the user has reviews) SCHEDULE_MIN_RATING='4' -# Minimum order completion rate (SUCCESS / total created), 0-1 +# Minimum order completion rate (SUCCESS over taken orders), 0-1 SCHEDULE_MIN_COMPLETION_RATE='0.9' +# Max consecutive uncompleted taken orders before a maker's schedules are removed as dormant (0 disables) +SCHEDULE_MAX_CONSECUTIVE_UNCOMPLETED='5' diff --git a/bot/modules/schedule/helpers.ts b/bot/modules/schedule/helpers.ts index b3bb4acc..8045b33e 100644 --- a/bot/modules/schedule/helpers.ts +++ b/bot/modules/schedule/helpers.ts @@ -178,3 +178,28 @@ export const checkScheduleRequirements = async ( return { ok: true }; }; + +// Hard limit on how many of a maker's most recent taken orders may go +// uncompleted in a row before their schedules are removed as dormant. +export const getDormantLimit = (): number => + envNumber(process.env.SCHEDULE_MAX_CONSECUTIVE_UNCOMPLETED, 5); + +// A maker is "dormant" when their last N taken orders all ended without +// success. Such a user keeps getting orders taken but never follows through, so +// their auto-published orders only waste takers' time. Returns false while +// there is not enough taken-order history to judge. +export const isDormantMaker = async (userId: string): Promise => { + const limit = getDormantLimit(); + if (limit <= 0) return false; + + const recent = await Order.find({ + creator_id: userId, + taken_at: { $ne: null }, + }) + .sort({ taken_at: -1 }) + .limit(limit) + .select('status'); + + if (recent.length < limit) return false; + return recent.every(order => order.status !== 'SUCCESS'); +}; diff --git a/jobs/scheduled_orders.ts b/jobs/scheduled_orders.ts index b059a129..c8e8f244 100644 --- a/jobs/scheduled_orders.ts +++ b/jobs/scheduled_orders.ts @@ -9,6 +9,10 @@ import { publishBuyOrderMessage, publishSellOrderMessage, } from '../bot/messages'; +import { + getDormantLimit, + isDormantMaker, +} from '../bot/modules/schedule/helpers'; // Runs every hour. For each active ScheduledOrder whose days/hour (UTC) match // now, publishes a brand-new Order from the stored mold. The mold is never @@ -25,8 +29,14 @@ const publishScheduledOrders = async (bot: Telegraf) => { hour: currentHour, }); + // Makers already handled as dormant in this run, so we don't deactivate + // their schedules or notify them more than once. + const dormantHandled = new Set(); + for (const schedule of schedules) { try { + if (dormantHandled.has(schedule.creator_id)) continue; + if (schedule.republish_count <= 0) { schedule.active = false; await schedule.save(); @@ -45,6 +55,30 @@ const publishScheduledOrders = async (bot: Telegraf) => { const i18n = await getUserI18nContext(user); + // Dormant maker enforcement: if the creator has left their last N taken + // orders uncompleted, remove all their schedules and notify them. + if (await isDormantMaker(schedule.creator_id)) { + const removed = await ScheduledOrder.updateMany( + { creator_id: schedule.creator_id, active: true }, + { active: false }, + ); + dormantHandled.add(schedule.creator_id); + try { + await bot.telegram.sendMessage( + user.tg_id, + i18n.t('schedule_dormant_removed', { count: getDormantLimit() }), + ); + } catch (error) { + logger.warning( + `ScheduledOrder: failed to notify dormant maker ${schedule.creator_id}: ${String(error)}`, + ); + } + logger.info( + `ScheduledOrder: deactivated ${removed.modifiedCount} schedule(s) for dormant maker ${schedule.creator_id}`, + ); + continue; + } + const order = await ordersActions.createOrder(i18n, bot, user, { type: schedule.type, amount: schedule.amount, diff --git a/locales/de.yaml b/locales/de.yaml index c39b7c57..a4db5ab1 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -748,6 +748,7 @@ schedule_req_completed_orders: "⚠️ Du benötigst mindestens ${count} abgesch schedule_req_volume: "⚠️ Du benötigst ein gehandeltes Volumen von mindestens ${volume} Sats, um geplante Aufträge zu erstellen." schedule_req_rating: "⚠️ Du benötigst eine Reputation von mindestens ${rating}, um geplante Aufträge zu erstellen." schedule_req_completion_rate: "⚠️ Deine Auftrags-Abschlussquote (${current}%) liegt unter den erforderlichen ${rate}%. Schließe mehr deiner Aufträge ab, bevor du planst." +schedule_dormant_removed: "⚠️ Ich habe alle deine geplanten Aufträge entfernt, weil du deine letzten ${count} angenommenen Aufträge nicht abgeschlossen hast." schedule_creation_cancelled: "❌ Erstellung des Schedules abgebrochen." schedule_created: "✅ Geplante Bestellung erstellt! Deine ID ist: ${scheduleId}\nVerwende /cancelschedule ${scheduleId} zum Stoppen." schedule_choose_days: "📅 An welchen Tagen soll die Bestellung veröffentlicht werden?" diff --git a/locales/en.yaml b/locales/en.yaml index 93e842bf..701c7ade 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -753,6 +753,7 @@ schedule_req_completed_orders: "⚠️ You need at least ${count} completed orde schedule_req_volume: "⚠️ You need a traded volume of at least ${volume} sats to create scheduled orders." schedule_req_rating: "⚠️ You need a reputation of at least ${rating} to create scheduled orders." schedule_req_completion_rate: "⚠️ Your order completion rate (${current}%) is below the required ${rate}%. Complete more of your orders before scheduling." +schedule_dormant_removed: "⚠️ I removed all your scheduled orders because you didn't complete your last ${count} taken orders." schedule_creation_cancelled: "❌ Schedule creation cancelled." schedule_created: "✅ Scheduled order created! Your ID is: ${scheduleId}\nUse /cancelschedule ${scheduleId} to stop it." schedule_choose_days: "📅 On which days should the order be published?" diff --git a/locales/es.yaml b/locales/es.yaml index 742bae48..db3623fd 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -751,6 +751,7 @@ schedule_req_completed_orders: "⚠️ Necesitás al menos ${count} órdenes com schedule_req_volume: "⚠️ Necesitás un volumen operado de al menos ${volume} sats para crear órdenes programadas." schedule_req_rating: "⚠️ Necesitás una reputación de al menos ${rating} para crear órdenes programadas." schedule_req_completion_rate: "⚠️ Tu tasa de completitud de órdenes (${current}%) está por debajo del ${rate}% requerido. Completá más de tus órdenes antes de programar." +schedule_dormant_removed: "⚠️ Eliminé todas tus órdenes programadas porque no completaste tus últimas ${count} órdenes tomadas." schedule_creation_cancelled: "❌ Creación de schedule cancelada." schedule_created: "✅ Orden programada creada. Tu ID es: ${scheduleId}\nUsa /cancelschedule ${scheduleId} para detenerla." schedule_choose_days: "📅 ¿En qué días debe publicarse la orden?" diff --git a/locales/fa.yaml b/locales/fa.yaml index c51f1855..317666b1 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -855,6 +855,7 @@ schedule_req_completed_orders: "⚠️ برای ساختن سفارش‌های schedule_req_volume: "⚠️ برای ساختن سفارش‌های زمان‌بندی‌شده به حجم معاملاتی حداقل ${volume} ساتوشی نیاز دارید." schedule_req_rating: "⚠️ برای ساختن سفارش‌های زمان‌بندی‌شده به اعتبار حداقل ${rating} نیاز دارید." schedule_req_completion_rate: "⚠️ نرخ تکمیل سفارش‌های شما (${current}%) کمتر از ${rate}% موردنیاز است. پیش از زمان‌بندی، سفارش‌های بیشتری را تکمیل کنید." +schedule_dormant_removed: "⚠️ همه سفارش‌های زمان‌بندی‌شده شما را حذف کردم، زیرا آخرین ${count} سفارش پذیرفته‌شده خود را تکمیل نکردید." schedule_creation_cancelled: "❌ ایجاد زمان‌بندی لغو شد." schedule_created: "✅ سفارش زمان‌بندی‌شده ایجاد شد! شناسه شما: ${scheduleId}\nبرای توقف از /cancelschedule ${scheduleId} استفاده کنید." schedule_choose_days: "📅 در چه روزهایی سفارش منتشر شود؟" diff --git a/locales/fr.yaml b/locales/fr.yaml index 43006337..ebe15f0c 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -747,6 +747,7 @@ schedule_req_completed_orders: "⚠️ Vous devez avoir au moins ${count} comman schedule_req_volume: "⚠️ Vous devez avoir un volume échangé d'au moins ${volume} sats pour créer des commandes planifiées." schedule_req_rating: "⚠️ Vous devez avoir une réputation d'au moins ${rating} pour créer des commandes planifiées." schedule_req_completion_rate: "⚠️ Votre taux de complétion des commandes (${current}%) est inférieur aux ${rate}% requis. Terminez davantage de vos commandes avant de planifier." +schedule_dormant_removed: "⚠️ J'ai supprimé toutes vos commandes planifiées car vous n'avez pas terminé vos ${count} dernières commandes acceptées." schedule_creation_cancelled: "❌ Création du schedule annulée." schedule_created: "✅ Ordre planifié créé ! Votre ID est : ${scheduleId}\nUtilisez /cancelschedule ${scheduleId} pour l'arrêter." schedule_choose_days: "📅 Quels jours l'ordre doit-il être publié ?" diff --git a/locales/it.yaml b/locales/it.yaml index 584497bf..f117866b 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -745,6 +745,7 @@ schedule_req_completed_orders: "⚠️ Ti servono almeno ${count} ordini complet schedule_req_volume: "⚠️ Ti serve un volume scambiato di almeno ${volume} sats per creare ordini programmati." schedule_req_rating: "⚠️ Ti serve una reputazione di almeno ${rating} per creare ordini programmati." schedule_req_completion_rate: "⚠️ Il tuo tasso di completamento degli ordini (${current}%) è inferiore al ${rate}% richiesto. Completa più dei tuoi ordini prima di programmare." +schedule_dormant_removed: "⚠️ Ho rimosso tutti i tuoi ordini programmati perché non hai completato i tuoi ultimi ${count} ordini presi." schedule_creation_cancelled: "❌ Creazione dello schedule annullata." schedule_created: "✅ Ordine programmato creato! Il tuo ID è: ${scheduleId}\nUsa /cancelschedule ${scheduleId} per fermarlo." schedule_choose_days: "📅 In quali giorni deve essere pubblicato l'ordine?" diff --git a/locales/ko.yaml b/locales/ko.yaml index 2d6b49db..448b7409 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -745,6 +745,7 @@ schedule_req_completed_orders: "⚠️ 예약 주문을 생성하려면 완료 schedule_req_volume: "⚠️ 예약 주문을 생성하려면 최소 ${volume} sats의 거래량이 필요합니다." schedule_req_rating: "⚠️ 예약 주문을 생성하려면 평판이 최소 ${rating} 이상이어야 합니다." schedule_req_completion_rate: "⚠️ 주문 완료율(${current}%)이 요구되는 ${rate}%보다 낮습니다. 예약하기 전에 더 많은 주문을 완료하세요." +schedule_dormant_removed: "⚠️ 최근 ${count}건의 수락된 주문을 완료하지 않아 모든 예약 주문을 삭제했습니다." schedule_creation_cancelled: "❌ 스케줄 생성이 취소되었습니다." schedule_created: "✅ 예약 주문이 생성되었습니다! ID: ${scheduleId}\n중지하려면 /cancelschedule ${scheduleId}을 사용하세요." schedule_choose_days: "📅 어떤 요일에 주문을 게시할까요?" diff --git a/locales/pt.yaml b/locales/pt.yaml index 2bd7e948..1fd81f7b 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -747,6 +747,7 @@ schedule_req_completed_orders: "⚠️ Você precisa de pelo menos ${count} orde schedule_req_volume: "⚠️ Você precisa de um volume negociado de pelo menos ${volume} sats para criar ordens programadas." schedule_req_rating: "⚠️ Você precisa de uma reputação de pelo menos ${rating} para criar ordens programadas." schedule_req_completion_rate: "⚠️ Sua taxa de conclusão de ordens (${current}%) está abaixo dos ${rate}% exigidos. Conclua mais das suas ordens antes de programar." +schedule_dormant_removed: "⚠️ Removi todas as suas ordens programadas porque você não concluiu suas últimas ${count} ordens aceitas." schedule_creation_cancelled: "❌ Criação do schedule cancelada." schedule_created: "✅ Ordem programada criada! Seu ID é: ${scheduleId}\nUse /cancelschedule ${scheduleId} para pará-la." schedule_choose_days: "📅 Em quais dias a ordem deve ser publicada?" diff --git a/locales/ru.yaml b/locales/ru.yaml index 5a7b627b..fd7b4346 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -748,6 +748,7 @@ schedule_req_completed_orders: "⚠️ Вам нужно не менее ${count schedule_req_volume: "⚠️ Вам нужен торговый объём не менее ${volume} сатоши, чтобы создавать запланированные заявки." schedule_req_rating: "⚠️ Вам нужна репутация не менее ${rating}, чтобы создавать запланированные заявки." schedule_req_completion_rate: "⚠️ Ваш показатель завершения ордеров (${current}%) ниже требуемых ${rate}%. Завершите больше своих ордеров, прежде чем планировать." +schedule_dormant_removed: "⚠️ Я удалил все ваши запланированные заявки, потому что вы не завершили свои последние ${count} принятых ордеров." schedule_creation_cancelled: "❌ Создание расписания отменено." schedule_created: "✅ Плановый ордер создан! Ваш ID: ${scheduleId}\nИспользуйте /cancelschedule ${scheduleId} для остановки." schedule_choose_days: "📅 В какие дни публиковать ордер?" diff --git a/locales/uk.yaml b/locales/uk.yaml index d2327b96..a29019fd 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -744,6 +744,7 @@ schedule_req_completed_orders: "⚠️ Вам потрібно щонаймен schedule_req_volume: "⚠️ Вам потрібен торговий обсяг щонайменше ${volume} сатоші, щоб створювати заплановані заявки." schedule_req_rating: "⚠️ Вам потрібна репутація щонайменше ${rating}, щоб створювати заплановані заявки." schedule_req_completion_rate: "⚠️ Ваш показник завершення ордерів (${current}%) нижчий за необхідні ${rate}%. Завершіть більше своїх ордерів, перш ніж планувати." +schedule_dormant_removed: "⚠️ Я видалив усі ваші заплановані заявки, тому що ви не завершили свої останні ${count} прийнятих ордерів." schedule_creation_cancelled: "❌ Створення розкладу скасовано." schedule_created: "✅ Плановий ордер створено! Ваш ID: ${scheduleId}\nВикористовуйте /cancelschedule ${scheduleId} для зупинки." schedule_choose_days: "📅 У які дні публікувати ордер?" From a6142d566b8539b1a00a5aa0a3b80f161416af3f Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 6 Jul 2026 00:19:10 +0100 Subject: [PATCH 19/19] revert: drop next-publication log and minutesUntilNextRun helper --- bot/modules/schedule/helpers.ts | 16 ---------------- bot/modules/schedule/scenes.ts | 12 +----------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/bot/modules/schedule/helpers.ts b/bot/modules/schedule/helpers.ts index 8045b33e..f7372e6a 100644 --- a/bot/modules/schedule/helpers.ts +++ b/bot/modules/schedule/helpers.ts @@ -45,22 +45,6 @@ export const parseCustomDays = (input: string): number[] | null => { return [...days].sort((a, b) => a - b); }; -// Minutes from now until the next time the scheduler will publish an order for -// the given UTC weekdays (0=Sun..6=Sat) and hour (0-23). The job runs hourly on -// the hour, so publication happens at minute 0 of the matching hour. -export const minutesUntilNextRun = (days: number[], hour: number): number => { - const now = new Date(); - for (let offset = 0; offset < 8; offset++) { - const candidate = new Date(now); - candidate.setUTCDate(now.getUTCDate() + offset); - candidate.setUTCHours(hour, 0, 0, 0); - if (days.includes(candidate.getUTCDay()) && candidate > now) { - return Math.round((candidate.getTime() - now.getTime()) / 60000); - } - } - return 0; -}; - export const PRESET_DAYS: Record = { daily: [0, 1, 2, 3, 4, 5, 6], weekdays: [1, 2, 3, 4, 5], diff --git a/bot/modules/schedule/scenes.ts b/bot/modules/schedule/scenes.ts index 6312e4d4..e32ce3bc 100644 --- a/bot/modules/schedule/scenes.ts +++ b/bot/modules/schedule/scenes.ts @@ -4,12 +4,7 @@ import { ScheduledOrder } from '../../../models'; import { CommunityContext } from '../community/communityContext'; import { UserDocument } from '../../../models/user'; import * as messages from './messages'; -import { - getRepublishCount, - minutesUntilNextRun, - parseCustomDays, - PRESET_DAYS, -} from './helpers'; +import { getRepublishCount, parseCustomDays, PRESET_DAYS } from './helpers'; export const SCHEDULE_ORDER = 'SCHEDULE_ORDER_WIZARD'; @@ -142,11 +137,6 @@ export const scheduleOrderWizard = new Scenes.WizardScene( active: true, }); - const minutesToPublish = minutesUntilNextRun(state.days, state.hour); - logger.info( - `ScheduledOrder ${schedule._id} created, next publication in ${minutesToPublish} minutes`, - ); - await ctx.reply( ctx.i18n.t('schedule_created', { scheduleId: schedule._id }), );