Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ DISABLE_LN_ADDRESS=
# Here will go the disputes from non community orders
DISPUTE_CHANNEL='@p2plnbotDispute'

# Maximum values for counterparty requirements that users can set
MAX_COUNTERPARTY_AGE_REQUIREMENT=30
MAX_COUNTERPARTY_ORDERS_REQUIREMENT=10

# time-to-live for communities in days, communities without successful orders on this time are deleted
COMMUNITY_TTL=31

Expand Down
38 changes: 38 additions & 0 deletions bot/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,43 @@ const userTakerIsBlockedByUserOrder = async (
}
};

const notMeetingRequirementsMessage = async (
ctx: MainContext,
user: UserDocument,
requirements?: {
failures: { age: boolean; orders: boolean };
min_days_using_bot: number;
min_completed_orders: number;
user_age: number | typeof NaN;
user_trades: number;
},
) => {
try {
const lines = [ctx.i18n.t('not_meeting_requirements_header')];

if (requirements?.failures.age) {
lines.push(
ctx.i18n.t('not_meeting_age_detail', {
required: requirements.min_days_using_bot,
actual: requirements.user_age,
}),
);
}
if (requirements?.failures.orders) {
lines.push(
ctx.i18n.t('not_meeting_orders_detail', {
required: requirements.min_completed_orders,
actual: requirements.user_trades,
}),
);
}

await ctx.telegram.sendMessage(user.tg_id, lines.join('\n'));
} catch (error) {
logger.error(error);
}
};

const fiatSentMessages = async (
ctx: MainContext,
buyer: UserDocument,
Expand Down Expand Up @@ -2248,4 +2285,5 @@ export {
userTakerIsBlockedByUserOrder,
userOrderIsBlockedByUserTaker,
showQRCodeMessage,
notMeetingRequirementsMessage,
};
48 changes: 48 additions & 0 deletions bot/modules/orders/takeOrder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
deleteOrderFromChannel,
generateRandomImage,
PerOrderIdMutex,
getUserAge,
} from '../../../util';
import * as messages from '../../messages';
import { HasTelegram, MainContext } from '../../start';
Expand Down Expand Up @@ -80,6 +81,8 @@ export const takebuy = async (

if (!(await validateTakeBuyOrder(ctx, bot, user, order))) return;

if (!(await meetsCounterpartyRequirements(ctx, user, userOffer))) return;

Copy link
Copy Markdown
Member

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, PENDING status…) live in validateTakeBuyOrder/validateTakeSellOrder in bot/validations.ts (also re-exported from bot/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 on takebuy/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


const { randomImage } = generateRandomImage(user._id.toString());

order.status = 'WAITING_PAYMENT';
Expand Down Expand Up @@ -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());
Expand All @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Design: the NaN guard is one level too shallow — it belongs in getUserAge.

getUserAge has 6 callers and only this new one guards NaN. The others interpolate the raw result into user-facing text: getDetailedOrder (util/index.ts:431), bot/messages.ts:90 and :537, bot/ordersActions.ts:257, and the Nostr event tag (bot/modules/nostr/events.ts:27) all render a literal NaN for the same legacy accounts this comment describes. Handling invalid created_at inside getUserAge itself would cover every caller and remove this special case.

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,
Expand Down
124 changes: 124 additions & 0 deletions bot/modules/user/scenes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

  • Number.isInteger(parsed) && parsed >= 0 here is duplicated (negated) inside makeRequirementCommand (!Number.isInteger(parsed) || parsed < 0). Extracting a shared isNonNegativeInt predicate keeps the two paths from drifting.
  • Elsewhere the repo reads numeric env vars with parseInt(process.env.X || 'N', 10) (bot/middleware/commandlogging.ts:7, bot/modules/community/scenes.ts:948, models/community.ts:4). Number() behaves differently on the same input (parseInt('10abc', 10) === 10 vs Number('10abc') → NaN), so identical-looking config values parse under different rules depending on the variable. Number() is arguably the safer of the two — just noting the inconsistency so it's a deliberate choice.

Generated by Claude Code


const DEFAULT_COUNTERPARTY_REQUIREMENTS = {
min_days_using_bot: 0,
min_completed_orders: 0,
};
Comment thread
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) {
Expand All @@ -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({
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 /counterpartyage 5 (message updates, shows the "updated to 5 days" feedback line), then runs /counterpartyage 5 again. resetMessage only deletes state.feedback from state — it doesn't re-render — so the displayed message still contains the old feedback line. The handler re-saves and sets identical feedback, updateMessage recomposes a byte-identical string, the messageChanged guard skips editMessageText, and ctx.deleteMessage() has already removed the user's command. Net result: the command vanishes with zero acknowledgement.

Fix: have resetMessage re-render (without the previous feedback/error) before calling next(), so the post-command updateMessage always differs from the displayed text.


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

Expand Down
33 changes: 26 additions & 7 deletions locales/de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ help: |
/setaddress <_lightning Adresse / off_> - Ermöglicht es dem Käufer, eine statische Zahlungsadresse (Lightning-Adresse) einzurichten, _off_ zum Deaktivieren
/setlang - Ermöglicht dem Benutzer, die Sprache zu ändern
/settings - Zeigt die aktuellen Einstellungen des Benutzers an
Innerhalb von /settings kannst du Gegenpartei-Anforderungen konfigurieren:
/counterpartyage <Tage> - Legt das Mindestalter (in Tagen) für die Gegenpartei fest
/counterpartyorders <Aufträge> - Legt die Mindestanzahl abgeschlossener Aufträge für die Gegenpartei fest
/resetrequirements - Setzt die Anforderungen auf Standardwerte zurück
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/listorders - Benutze diesen Befehl, um alle deine ausstehenden Transaktionen aufzulisten
/listcurrencies - Listet alle FIAT Währungen auf, die wir verwenden können
/fiatsent <_order id_> - Der Käufer teilt mit, dass er dem Verkäufer das FIAT-Geld geschickt hat
Expand Down Expand Up @@ -631,20 +635,35 @@ order_not_found: Bestellung nicht gefunden.

# START modules/user
user_settings: |
<strong>User settings for @${user.username}</strong>
<strong>Benutzereinstellungen für @${user.username}</strong>

Language:
Sprache:
${language.emoji} ${language.name}
Community:
Gemeinschaft:
${community || '🛇'}
npub:
<code>${npub || '🛇'}</code>
lightning address:
Lightning-Adresse:
<code>${lightning_address || '🛇'}</code>

<strong># HELP</strong>
/setnpub &lt;npub&gt; - Configure user's Nostr public key.
/exit - to exit the wizard.
<strong>Gegenpartei-Anforderungen</strong>
Mindestalter: ${min_days_using_bot} Tage
Mindestanzahl abgeschlossener Aufträge: ${min_completed_orders}

<strong># HILFE</strong>
/setnpub &lt;npub&gt; - Nostr-Schlüssel des Benutzers konfigurieren.
/counterpartyage &lt;Tage&gt; - Legt das Mindestalter (in Tagen) für die Gegenpartei fest.
/counterpartyorders &lt;Aufträge&gt; - Legt die Mindestanzahl abgeschlossener Aufträge fest.
/resetrequirements - Setzt die Gegenpartei-Anforderungen auf Standardwerte zurück.
/exit - um den Assistenten zu verlassen.
not_meeting_requirements_header: "No cumples con los requisitos de la contraparte para tomar esta orden:"
not_meeting_age_detail: "Edad requerida: ${required} días: Tu edad: ${actual} días"
not_meeting_orders_detail: "Órdenes requeridas: ${required}: Tu cantidad de órdenes: ${actual}"
counterpartyage_updated: Altersanforderung der Gegenpartei auf ${days} Tage aktualisiert.
counterpartyorders_updated: Auftragsanforderung der Gegenpartei auf ${orders} aktualisiert.
requirements_reset: Gegenpartei-Anforderungen wurden auf Standardwerte zurückgesetzt.
invalid_number: Ungültige Zahl.
invalid_range: Ungültiger Wert für ${command}, bitte wähle eine Zahl im Bereich [0 - ${max}].
# END modules/user
# check hold invoice
invoice_settled: Rechnung wurde bereits niedergelassen
Expand Down
21 changes: 20 additions & 1 deletion locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,11 @@ help: |
/setaddress <_lightning address / off_> - Allows the buyer to establish a static payment address (lightning address), _off_ to deactivate
/setlang - Allows the user to change the language
/settings - Displays the user's current settings
/listorders - Use this command to list all your pending transactions
While in /settings you can configure counterparty requirements:
/counterpartyage <days> - Sets the minimum account age (in days) required for the counterparty to take your orders
/counterpartyorders <orders> - Sets the minimum number of completed orders required for the counterparty
/resetrequirements - Resets counterparty requirements to default
/listorders - Use this command to list all your pending transactions
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/listcurrencies - Lists all the currencies we can use to without indicatin the amount in sats.
/fiatsent <_order id_> - Buyer informs that he has already sent FIAT money to seller
/release <_order id_> - Seller releases satoshis
Expand Down Expand Up @@ -649,9 +653,24 @@ user_settings: |
lightning address:
<code>${lightning_address || '🛇'}</code>

<strong>Counterparty requirements</strong>
Min. account age: ${min_days_using_bot} days
Min. completed orders: ${min_completed_orders}

<strong># HELP</strong>
/setnpub &lt;npub&gt; - Configure user's Nostr public key.
/counterpartyage &lt;days&gt; - Sets the minimum account age (in days) for the counterparty.
/counterpartyorders &lt;orders&gt; - Sets the minimum number of completed orders for the counterparty.
/resetrequirements - Resets counterparty requirements to default.
/exit - to exit the wizard.
not_meeting_requirements_header: "You do not meet the counterparty's requirements to take this order:"
not_meeting_age_detail: "Required age: ${required} days: Your age: ${actual} days"
not_meeting_orders_detail: "Required orders: ${required}: Your number of orders: ${actual}"
counterpartyage_updated: Counterparty age requirement updated to ${days} days.
counterpartyorders_updated: Counterparty completed orders requirement updated to ${orders}.
requirements_reset: Counterparty requirements have been reset to default.
invalid_number: Invalid number.
invalid_range: Invalid value for ${command}, please choose a number in the range [0 - ${max}].
# END modules/user
# check hold invoice
invoice_settled: Invoice already settled
Expand Down
Loading
Loading