-
Notifications
You must be signed in to change notification settings - Fork 137
Add counterparty requirements #853
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
Changes from all commits
f8cf066
74544a9
50e74c6
e3e5bad
2357197
9697b2a
02a7e0b
a7b42d1
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 |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import { | |
| deleteOrderFromChannel, | ||
| generateRandomImage, | ||
| PerOrderIdMutex, | ||
| getUserAge, | ||
| } from '../../../util'; | ||
| import * as messages from '../../messages'; | ||
| import { HasTelegram, MainContext } from '../../start'; | ||
|
|
@@ -80,6 +81,8 @@ export const takebuy = async ( | |
|
|
||
| if (!(await validateTakeBuyOrder(ctx, bot, user, order))) return; | ||
|
|
||
| if (!(await meetsCounterpartyRequirements(ctx, user, userOffer))) return; | ||
|
|
||
| const { randomImage } = generateRandomImage(user._id.toString()); | ||
|
|
||
| order.status = 'WAITING_PAYMENT'; | ||
|
|
@@ -134,7 +137,10 @@ export const takesell = async ( | |
| // We verify if the user is not banned on this community | ||
| if (await isBannedFromCommunity(user, order.community_id)) | ||
| return await messages.bannedUserErrorMessage(ctx, user); | ||
|
|
||
| if (!(await validateTakeSellOrder(ctx, bot, user, order))) return; | ||
|
|
||
| if (!(await meetsCounterpartyRequirements(ctx, user, seller))) return; | ||
| order.status = 'WAITING_BUYER_INVOICE'; | ||
| order.buyer_id = user._id; | ||
| order.taken_at = new Date(Date.now()); | ||
|
|
@@ -152,6 +158,48 @@ export const takesell = async ( | |
| } | ||
| }; | ||
|
|
||
| export const meetsCounterpartyRequirements = async ( | ||
| ctx: MainContext, | ||
| user: UserDocument, | ||
| orderCreator: UserDocument, | ||
| ) => { | ||
| if (!orderCreator.counterparty_requirements) return true; | ||
|
|
||
| const { min_days_using_bot, min_completed_orders } = | ||
| orderCreator.counterparty_requirements; | ||
|
|
||
| const failures = { | ||
| age: false, | ||
| orders: false, | ||
| }; | ||
|
|
||
| if (min_days_using_bot > 0) { | ||
| const ageInDays = getUserAge(user); | ||
| if (!Number.isNaN(ageInDays) && ageInDays < min_days_using_bot) { | ||
|
Comment on lines
+177
to
+178
Member
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. Design: the NaN guard is one level too shallow — it belongs in
The catch is picking a return value that works for both display and comparison call sites, which is a product decision — flagging it rather than prescribing one. Fine to defer to a follow-up, but the helper is the right place for the fix. Generated by Claude Code |
||
| failures.age = true; | ||
| } | ||
| } | ||
|
|
||
| if (min_completed_orders > 0) { | ||
| if (user.trades_completed < min_completed_orders) { | ||
| failures.orders = true; | ||
| } | ||
| } | ||
|
|
||
| if (failures.age || failures.orders) { | ||
| await messages.notMeetingRequirementsMessage(ctx, user, { | ||
| failures, | ||
| min_days_using_bot, | ||
| min_completed_orders, | ||
| user_age: getUserAge(user), | ||
| user_trades: user.trades_completed, | ||
| }); | ||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| }; | ||
|
|
||
| const checkBlockingStatus = async ( | ||
| ctx: MainContext, | ||
| user: UserDocument, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,12 +7,33 @@ import { | |
| CommunityWizardState, | ||
| } from '../../community/communityContext'; | ||
| import { Message } from 'telegraf/typings/core/types/typegram'; | ||
| import { logger } from '../../../../logger'; | ||
|
|
||
| const isNonNegativeInt = (value: number) => | ||
| Number.isInteger(value) && value >= 0; | ||
|
|
||
| const readNonNegativeInt = (value: string | undefined, fallback: number) => { | ||
| if (value === undefined || value.trim() === '') return fallback; | ||
| const parsed = parseInt(value, 10); | ||
| return isNonNegativeInt(parsed) ? parsed : fallback; | ||
| }; | ||
|
Comment on lines
+15
to
+19
Member
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. Cleanup: the non-negative-integer rule is written twice in this file, and the env parsing diverges from the codebase idiom.
Generated by Claude Code |
||
|
|
||
| const DEFAULT_COUNTERPARTY_REQUIREMENTS = { | ||
| min_days_using_bot: 0, | ||
| min_completed_orders: 0, | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // Fallback caps when the corresponding MAX_COUNTERPARTY_* env vars are unset, | ||
| // mirroring the values documented in .env-sample. | ||
| const DEFAULT_MAX_COUNTERPARTY_AGE = 30; | ||
| const DEFAULT_MAX_COUNTERPARTY_ORDERS = 10; | ||
|
|
||
| function make() { | ||
| const resetMessage = async (ctx: CommunityContext, next: () => void) => { | ||
| const state = ctx.scene.state as CommunityWizardState; | ||
| delete state.feedback; | ||
| delete state.error; | ||
| await updateMessage(ctx); | ||
| next(); | ||
| }; | ||
| async function mainData(ctx: CommunityContext) { | ||
|
|
@@ -24,6 +45,10 @@ function make() { | |
| npub: '', | ||
| community: '', | ||
| lightning_address: '', | ||
| min_days_using_bot: | ||
| user.counterparty_requirements?.min_days_using_bot ?? 0, | ||
| min_completed_orders: | ||
| user.counterparty_requirements?.min_completed_orders ?? 0, | ||
| }; | ||
| if (user.default_community_id) { | ||
| const community = await Community.findOne({ | ||
|
|
@@ -131,6 +156,105 @@ function make() { | |
| } | ||
| }); | ||
|
|
||
| // counterpartyage and counterpartyorders only differ in the field they set, | ||
| // the env cap, its fallback, and the feedback key/param, so we build both | ||
| // from a single factory. | ||
| const makeRequirementCommand = ({ | ||
| command, | ||
| envVar, | ||
| fallbackMax, | ||
| field, | ||
| feedbackKey, | ||
| paramKey, | ||
| }: { | ||
| command: string; | ||
| envVar: string; | ||
| fallbackMax: number; | ||
| field: 'min_days_using_bot' | 'min_completed_orders'; | ||
| feedbackKey: string; | ||
| paramKey: string; | ||
| }) => { | ||
| scene.command(command, resetMessage, async (ctx: CommunityContext) => { | ||
| try { | ||
| await ctx.deleteMessage(); | ||
| const state = ctx.scene.state as CommunityWizardState; | ||
| if (ctx.message === undefined || !('text' in ctx.message)) | ||
| throw new Error('ctx.message is undefined'); | ||
| const [, value] = ctx.message.text.trim().split(/\s+/); | ||
| const parsed = parseInt(value, 10); | ||
| if (!isNonNegativeInt(parsed)) throw new Error('NotValidNumber'); | ||
| const max = readNonNegativeInt(process.env[envVar], fallbackMax); | ||
| if (parsed > max) { | ||
| state.error = { | ||
| i18n: 'invalid_range', | ||
| command: '/' + command, | ||
| max, | ||
| }; | ||
| return await updateMessage(ctx); | ||
| } | ||
| const user = state.user; | ||
| if (!user.counterparty_requirements) { | ||
| user.counterparty_requirements = { | ||
| ...DEFAULT_COUNTERPARTY_REQUIREMENTS, | ||
| }; | ||
| } | ||
| user.counterparty_requirements[field] = parsed; | ||
| await user.save(); | ||
| state.feedback = { i18n: feedbackKey, [paramKey]: parsed }; | ||
|
Comment on lines
+202
to
+203
Member
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. UX: repeating a command with the same value produces no visible response at all. Sequence: user runs Fix: have Generated by Claude Code |
||
| await updateMessage(ctx); | ||
| } catch (err) { | ||
| logger.error(err); | ||
| (ctx.scene.state as CommunityWizardState).error = { | ||
| i18n: | ||
| err instanceof Error && err.message === 'NotValidNumber' | ||
| ? 'invalid_number' | ||
| : 'generic_error', | ||
| }; | ||
| await updateMessage(ctx); | ||
| } | ||
| }); | ||
| }; | ||
|
|
||
| makeRequirementCommand({ | ||
| command: 'counterpartyage', | ||
| envVar: 'MAX_COUNTERPARTY_AGE_REQUIREMENT', | ||
| fallbackMax: DEFAULT_MAX_COUNTERPARTY_AGE, | ||
| field: 'min_days_using_bot', | ||
| feedbackKey: 'counterpartyage_updated', | ||
| paramKey: 'days', | ||
| }); | ||
|
|
||
| makeRequirementCommand({ | ||
| command: 'counterpartyorders', | ||
| envVar: 'MAX_COUNTERPARTY_ORDERS_REQUIREMENT', | ||
| fallbackMax: DEFAULT_MAX_COUNTERPARTY_ORDERS, | ||
| field: 'min_completed_orders', | ||
| feedbackKey: 'counterpartyorders_updated', | ||
| paramKey: 'orders', | ||
| }); | ||
|
|
||
| scene.command( | ||
| 'resetrequirements', | ||
| resetMessage, | ||
| async (ctx: CommunityContext) => { | ||
| try { | ||
| await ctx.deleteMessage(); | ||
| const state = ctx.scene.state as CommunityWizardState; | ||
| const user = state.user; | ||
| user.counterparty_requirements = undefined; | ||
| await user.save(); | ||
| state.feedback = { i18n: 'requirements_reset' }; | ||
| await updateMessage(ctx); | ||
| } catch (err) { | ||
| logger.error(err); | ||
| (ctx.scene.state as CommunityWizardState).error = { | ||
| i18n: 'generic_error', | ||
| }; | ||
| await updateMessage(ctx); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| return scene; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Design: the gate sits beside the shared take validators instead of inside them.
All sibling take-path checks (own order, order type,
PENDINGstatus…) live invalidateTakeBuyOrder/validateTakeSellOrderinbot/validations.ts(also re-exported frombot/index.ts). The new counterparty rule is conceptually one of those checks but is called separately at two call sites. Today no path bypasses it — text commands and callback buttons both converge ontakebuy/takesell— but any future take path that reuses the validators (a new scene, a community-side take) will pass validation and silently skip this rule.Not blocking (it would require loading the order creator inside the validators), but worth considering moving the check into the validators so every take path gets it by construction.
Generated by Claude Code