Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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
18 changes: 18 additions & 0 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,21 @@ 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 consecutive uncompleted taken orders before a maker's schedules are removed as dormant (0 disables)
SCHEDULE_MAX_CONSECUTIVE_UNCOMPLETED='5'
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
149 changes: 149 additions & 0 deletions bot/modules/schedule/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { Types } from 'mongoose';
import { CommunityContext } from '../community/communityContext';
import { ScheduledOrder } from '../../../models';
import { validateSellOrder, validateBuyOrder } from '../../validations';
import { getCurrency } 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 {
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) => {
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);
}
};
189 changes: 189 additions & 0 deletions bot/modules/schedule/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { Order, ScheduledOrder } from '../../../models';
import { UserDocument } from '../../../models/user';

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

// English day names (full and abbreviated) mapped to getUTCDay() values (0=Sun..6=Sat)
const DAY_ALIASES: Record<string, number> = {
sunday: 0,
sun: 0,
monday: 1,
mon: 1,
tuesday: 2,
tue: 2,
wednesday: 3,
wed: 3,
thursday: 4,
thu: 4,
friday: 5,
fri: 5,
saturday: 6,
sat: 6,
};

// 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);
if (parts.length === 0) return null;
const days = new Set<number>();
for (const part of parts) {
const dayNum = DAY_ALIASES[part];
// 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);
};

Comment on lines +31 to +47

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This parsing is a little bit hacky for me, BUT i don't see it as a blocker for this PR.
It forces the user of the command to express the days he wants to schedule the order only in english. Definetly not the best UI.
BUT, considering that the command would be targeted to experienced users, it's ok for me in a first implementation.
Given that the command will be used sporadically, I think we should improve this UI only if the command becomes widely used and users don't fully understand the command.

export const PRESET_DAYS: Record<string, number[]> = {
daily: [0, 1, 2, 3, 4, 5, 6],
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;
};
Comment on lines +54 to +57

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.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

envNumber treats an empty env var as 0, silently disabling gates.

Number('') is 0, which is finite, so a present-but-empty variable bypasses the fallback. For example SCHEDULE_MIN_COMPLETION_RATE= (or SCHEDULE_MIN_RATING=) resolves to 0 instead of the intended default, quietly turning off the anti-spam check. Guard against blank/whitespace input.

🛡️ Proposed fix
 const envNumber = (raw: string | undefined, fallback: number): number => {
-  const value = Number(raw);
-  return Number.isFinite(value) ? value : fallback;
+  if (raw === undefined || raw.trim() === '') return fallback;
+  const value = Number(raw);
+  return Number.isFinite(value) ? value : fallback;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const envNumber = (raw: string | undefined, fallback: number): number => {
const value = Number(raw);
return Number.isFinite(value) ? value : fallback;
};
const envNumber = (raw: string | undefined, fallback: number): number => {
if (raw === undefined || raw.trim() === '') return fallback;
const value = Number(raw);
return Number.isFinite(value) ? value : fallback;
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bot/modules/schedule/helpers.ts` around lines 70 - 73, The envNumber helper
currently converts blank strings to 0 because Number('') is finite, which
bypasses the fallback and can disable schedule gates. Update envNumber in
helpers.ts to treat undefined, empty, or whitespace-only raw values as missing
and return the fallback before calling Number, while keeping the existing
finite-number validation for real inputs. Use the envNumber symbol so the fix
applies to all callers like SCHEDULE_MIN_COMPLETION_RATE and
SCHEDULE_MIN_RATING.


export interface ScheduleRequirementResult {
ok: boolean;
messageKey?: string;
params?: Record<string, unknown>;
}

// 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<ScheduleRequirementResult> => {
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 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 / takenOrders;
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 };
};

// 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<boolean> => {
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');
};
Comment on lines +171 to +189

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the Order model and inspect the status field / enum values.
fd -a 'order.ts' models
ast-grep outline models/order.ts --items all --match 'status'
rg -n -A5 -B2 "status" models/order.ts | head -80

Repository: lnp2pBot/bot

Length of output: 1061


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the full status enum and any lifecycle helpers for Order.
sed -n '1,220p' models/order.ts | cat -n
printf '\n--- search for terminal/state helpers ---\n'
rg -n "terminal|is.*terminal|SUCCESS|FAILED|CANCELED|EXPIRED|DISPUTE|FIAT_SENT|ACTIVE" models bot | head -200

Repository: lnp2pBot/bot

Length of output: 10599


Exclude in-flight orders from isDormantMaker ACTIVE, FIAT_SENT, and DISPUTE are still open states here, so counting any non-SUCCESS taken order can mark an engaged maker dormant too early. Only evaluate terminal outcomes, or skip orders that haven’t finished yet.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bot/modules/schedule/helpers.ts` around lines 171 - 189, Exclude in-flight
orders from isDormantMaker: the current Order query in isDormantMaker treats
every taken order that is not SUCCESS as evidence of dormancy, which can
incorrectly include ACTIVE, FIAT_SENT, and DISPUTE. Update the recent-order
filtering logic in isDormantMaker to consider only terminal outcomes (or
explicitly skip open states) before applying the dormant check, while keeping
the existing limit and history-length behavior intact.

Loading
Loading