-
Notifications
You must be signed in to change notification settings - Fork 138
feat: add /scheduleorder and /cancelschedule for recurring auto-republishing orders #849
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
26b89f5
d867784
f73a432
2c56591
0704d18
73d0f42
30d6ac6
5fb65f3
971a00a
6791463
6e18662
b5e9792
8c17b63
9b5fe9d
5d46b9d
0ccbbeb
5c21fc0
d957c15
a6142d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
| 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); | ||
| } | ||
| }; | ||
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🛡️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
|
|
||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 -80Repository: 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 -200Repository: lnp2pBot/bot Length of output: 10599 Exclude in-flight orders from 🤖 Prompt for AI Agents |
||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.