Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
26b89f5
feat: add /scheduleorder and /cancelschedule commands for recurring o…
Matobi98 Jun 29, 2026
d867784
refactor: migrate schedule flow from global listeners to WizardScene
Matobi98 Jun 29, 2026
f73a432
i18n: translate schedule command strings to all 9 supported locales
Matobi98 Jun 29, 2026
2c56591
i18n: add schedule commands to /help text in all locales
Matobi98 Jun 29, 2026
0704d18
fix: address coderabbit review — parallel refresh, publish guard, Obj…
Matobi98 Jun 30, 2026
73d0f42
fix: remove _id: string override from IScheduledOrder to satisfy stri…
Matobi98 Jun 30, 2026
30d6ac6
style: run prettier format
Matobi98 Jun 30, 2026
5fb65f3
fix: translate order type in schedule summary and enable Markdown bol…
Matobi98 Jun 30, 2026
971a00a
fix: strict hour validation, publish guard in cron job, fix malformed…
Matobi98 Jun 30, 2026
6791463
fix: detect publish success via order side effects instead of void re…
Matobi98 Jun 30, 2026
6e18662
fix: guard day parsing against inherited Object.prototype keys (const…
Matobi98 Jun 30, 2026
b5e9792
fix: reserve schedule cycle atomically before publish to avoid duplic…
Matobi98 Jul 1, 2026
8c17b63
fix: remove scheduled order republish-on-take to avoid flooding the o…
Matobi98 Jul 5, 2026
9b5fe9d
feat: add /listschedules and /cancelallschedules commands for schedul…
Matobi98 Jul 5, 2026
5d46b9d
feat: gate scheduled order creation behind anti-spam requirements
Matobi98 Jul 5, 2026
0ccbbeb
fix: base schedule completion rate on taken orders only
Matobi98 Jul 5, 2026
5c21fc0
feat: log minutes until next publication when a schedule is created
Matobi98 Jul 5, 2026
d957c15
feat: remove schedules of dormant makers who leave taken orders uncom…
Matobi98 Jul 5, 2026
a6142d5
revert: drop next-publication log and minutesUntilNextRun helper
Matobi98 Jul 5, 2026
7e42629
fix: address schedule review — dormant window, private-only managemen…
Matobi98 Jul 16, 2026
5307ff7
fix: harden scheduled orders — pre-insert re-check, MAX_PENDING_ORDER…
Matobi98 Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,22 @@ 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 over taken orders), 0-1
SCHEDULE_MIN_COMPLETION_RATE='0.9'
# Max days a maker may go without completing any order before their schedules are removed as dormant (0 disables).
# Note: taken orders expire in ~27h (see ORDER_PUBLISHED_EXPIRATION_WINDOW)
SCHEDULE_MAX_DAYS_WITHOUT_COMPLETION='7'
2 changes: 2 additions & 0 deletions bot/middleware/stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +29,7 @@ export const stageMiddleware = () => {
addInvoicePHIWizard,
OrdersModule.Scenes.createOrder,
UserModule.Scenes.Settings,
scheduleOrderWizard,
];
scenes.forEach(addGenericCommands);
const stage = new Scenes.Stage(scenes, {
Expand Down
2 changes: 0 additions & 2 deletions bot/modules/orders/takeOrder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
validateTakeSellOrder,
validateUserWaitingOrder,
} from '../../validations';

const OrderEvents = require('../../modules/events/orders');

export const takeOrderActionValidation = async (
Expand Down Expand Up @@ -93,7 +92,6 @@ export const takebuy = async (
OrderEvents.orderUpdated(order);

await deleteOrderFromChannel(order, bot.telegram);

await messages.beginTakeBuyMessage(ctx, bot, user, order);
});
} catch (error) {
Expand Down
161 changes: 161 additions & 0 deletions bot/modules/schedule/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { Types } from 'mongoose';
import { CommunityContext } from '../community/communityContext';
import { ScheduledOrder } from '../../../models';
import { validateSellOrder, validateBuyOrder } from '../../validations';
import { getCurrency, sanitizeMD } from '../../../util';
import { logger } from '../../../logger';
import { SCHEDULE_ORDER } from './scenes';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { formatDays } from './messages';
import { checkScheduleRequirements } from './helpers';

export const scheduleorder = async (ctx: CommunityContext) => {
try {
// 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+/);

if (!type || !['buy', 'sell'].includes(type.toLowerCase())) {
return ctx.reply(ctx.i18n.t('scheduleorder_usage'));
}

const orderType = type.toLowerCase();

// 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;

const params =
orderType === 'sell'
? await validateSellOrder(ctx)
: await validateBuyOrder(ctx);

if (!params) return;

if (!getCurrency(params.fiatCode)) {
await ctx.reply(ctx.i18n.t('must_be_valid_currency'));
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,
amount: params.amount,
fiatAmount: params.fiatAmount,
fiatCode: params.fiatCode,
paymentMethod: params.paymentMethod,
priceMargin: params.priceMargin,
});
} catch (error) {
logger.error(error);
}
};

export const cancelschedule = async (ctx: CommunityContext) => {
try {
// Schedule management must stay in the private DM to avoid leaking or
// destroying schedule data from a group chat.
if (ctx.message?.chat.type !== 'private') return;

const user = ctx.user;
const args = ctx.state?.command?.args || [];
const scheduleId = args[0];

if (!scheduleId || !Types.ObjectId.isValid(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);
}
};

export const listschedules = async (ctx: CommunityContext) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These schedule-management commands should be private-chat only. userMiddleware does not enforce that, so /listschedules and /cancelallschedules can leak schedule metadata or perform destructive actions from a group chat. Please short-circuit non-private chats here just like scheduleorder already does.

try {
// Schedule management must stay in the private DM to avoid leaking or
// destroying schedule data from a group chat.
if (ctx.message?.chat.type !== 'private') return;

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

payment_method is interpolated into a parse_mode: "Markdown" reply without escaping. A user-entered value with Markdown characters can break /listschedules output. Please sanitize schedule.payment_method before passing it into the template.

.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: sanitizeMD(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 {
// Schedule management must stay in the private DM to avoid leaking or
// destroying schedule data from a group chat.
if (ctx.message?.chat.type !== 'private') return;

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);
}
};
Loading
Loading