Skip to content

Add interactive wizard for /dispute (button-based order selection) - #731

Merged
grunch merged 1 commit into
lnp2pBot:mainfrom
Matobi98:issue612
Mar 4, 2026
Merged

Add interactive wizard for /dispute (button-based order selection)#731
grunch merged 1 commit into
lnp2pBot:mainfrom
Matobi98:issue612

Conversation

@Matobi98

@Matobi98 Matobi98 commented Jan 30, 2026

Copy link
Copy Markdown
Contributor

Adds an interactive wizard for /dispute so users can pick their order via buttons instead of typing /dispute , reducing errors and friction.

Summary by CodeRabbit

  • New Features
    • Users can initiate disputes directly from order actions or by providing an order identifier; if none is provided an inline picker lists eligible orders to start the dispute flow.
  • Localization
    • Added an order-selection prompt across multiple locales (DE, EN, ES, FA, FR, IT, KO, PT, RU, UK). Italian and Ukrainian include expanded order-detail text for richer order views.

@coderabbitai

coderabbitai Bot commented Jan 30, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Extracts dispute initiation logic into a new handler handleDispute, adds an action entrypoint initiateDispute and bot.action route, provides an order-listing UI helper listOrdersForDispute, and adds choose_order_for_dispute translations across multiple locale files.

Changes

Cohort / File(s) Summary
Dispute actions
bot/modules/dispute/actions.ts
Added initiateDispute(ctx) which reads orderId from the action payload, deletes the trigger message, and delegates to handleDispute.
Dispute commands
bot/modules/dispute/commands.ts
Added and exported handleDispute(ctx, orderId) that runs the dispute initiation flow; refactored dispute to dispatch between listing orders and initiating a dispute.
Action registration
bot/modules/dispute/index.ts
Registered new bot.action initiateDispute_([0-9a-f]{24}) mapped to actions.initiateDispute with existing user middleware.
Order listing UI
bot/modules/dispute/messages.ts
Added listOrdersForDispute(ctx, orders) to build an inline keyboard and send the localized choose_order_for_dispute prompt.
Localizations
locales/en.yaml, locales/de.yaml, locales/es.yaml, locales/fa.yaml, locales/fr.yaml, locales/it.yaml, locales/ko.yaml, locales/pt.yaml, locales/ru.yaml, locales/uk.yaml
Added choose_order_for_dispute key in each locale. en.yaml updated a guidance sentence; it.yaml added additional keys (sell_sats, buy_sats, order_detail). Some locale files contain duplicate insertions.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • crate a wizard to disputes #612 — introduces a UI-driven dispute entrypoint and order-selection flow, which aligns with the new order-listing and initiateDispute action added here.

Possibly related PRs

Suggested reviewers

  • grunch
  • Luquitasjeffrey
  • Catrya

Poem

🐰 I hop through handlers, nibble a thread,

Buttons of orders lined up ahead.
Tap to begin, a tiny dispute song,
Locales chirp, the keyboard clicks along.
🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding an interactive wizard with button-based order selection for the /dispute command.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@bot/modules/dispute/commands.ts`:
- Around line 116-122: In the conditional in the command handler, remove the
redundant "return" statements after the awaited calls—specifically eliminate the
trailing returns after messages.listOrdersForDispute(ctx, orders) and after
ctx.reply(ctx.i18n.t('you_have_no_orders'))—so the branch ends with the awaited
calls only; keep the if/else logic intact and ensure no other control flow needs
those returns.

In `@bot/modules/dispute/messages.ts`:
- Line 4: The file imports the symbol User but never uses it; remove User from
the import statement (the import line that currently reads "import { User } from
...") so the file no longer imports the unused symbol and run/verify linting to
ensure no remaining unused-import warnings.
🧹 Nitpick comments (3)
locales/it.yaml (1)

188-198: Consider translating remaining English strings in order_detail.

Several labels within order_detail remain in English:

  • Line 188: Days in operation \\(buyer\\)
  • Line 190: Successful trades \\(buyer\\)
  • Line 196: Days in operation \\(seller\\)
  • Line 198: Successful trades \\(seller\\)

For consistency with the rest of the Italian locale, these should be translated.

bot/modules/dispute/actions.ts (1)

58-64: LGTM with minor suggestion.

The implementation is clean and properly delegates to handleDispute. For consistency with takeDispute above (which has explicit Promise<void> return type), consider adding the same type annotation.

Suggested improvement
-export const initiateDispute = async (ctx: MainContext) => {
+export const initiateDispute = async (ctx: MainContext): Promise<void> => {
bot/modules/dispute/messages.ts (1)

8-32: Consider simplifying the button mapping.

The function works correctly, but Promise.all with async callback is unnecessary here since there are no async operations inside the map. This can be simplified:

Suggested refactor
 export const listOrdersForDispute = async (
   ctx: MainContext,
   orders: IOrder[],
 ) => {
   try {
-    const buttons = await Promise.all(
-      orders.map(async order => {
-        return [
-          {
-            text: `${order._id.toString().substring(0, 2)}..${order._id.toString().slice(-2)} - ${order.type} - ${order.fiat_code} ${order.fiat_amount}`,
-            callback_data: `initiateDispute_${order._id}`,
-          },
-        ];
-      }),
-    );
+    const buttons = orders.map(order => [
+      {
+        text: `${order._id.toString().substring(0, 2)}..${order._id.toString().slice(-2)} - ${order.type} - ${order.fiat_code} ${order.fiat_amount}`,
+        callback_data: `initiateDispute_${order._id}`,
+      },
+    ]);

     await ctx.reply(ctx.i18n.t('choose_order_for_dispute'), {

Comment thread bot/modules/dispute/commands.ts
Comment thread bot/modules/dispute/messages.ts Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
bot/modules/dispute/commands.ts (1)

23-31: ⚠️ Potential issue | 🟠 Major

Validate DISPUTE_START_WINDOW to avoid bypassing the time gate.

parseInt can yield NaN (or a negative value), which makes the comparison against order.taken_at fail open and allows immediate disputes. Validate the parsed value before using it.

🔧 Suggested fix
-    const disputStartWindow = process.env.DISPUTE_START_WINDOW;
-    if (disputStartWindow === undefined)
+    const disputeStartWindow = process.env.DISPUTE_START_WINDOW;
+    if (disputeStartWindow === undefined)
       throw new Error('DISPUTE_START_WINDOW environment variable not defined');
-    const secsUntilDispute = parseInt(disputStartWindow);
+    const secsUntilDispute = Number.parseInt(disputeStartWindow, 10);
+    if (!Number.isFinite(secsUntilDispute) || secsUntilDispute < 0) {
+      throw new Error('DISPUTE_START_WINDOW must be a non-negative integer');
+    }
     const time = new Date();
     time.setSeconds(time.getSeconds() - secsUntilDispute);

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@bot/modules/dispute/commands.ts`:
- Around line 97-100: The conditional in the dispute command uses
ctx.state.command.args.length and then checks ctx.state.command.args[0] !==
'undefined' which tests the literal string "undefined" (redundant since length>0
proves an element exists); either remove the "'undefined'" comparison and rely
on the length check, or if you actually intend to treat the user typing the word
"undefined" as invalid, add a clarifying comment above this condition explaining
that special-case; update the condition in the handler that reads
ctx.state.command.args (the if block containing args.length and args[0] !==
'undefined') accordingly.
🧹 Nitpick comments (4)
bot/modules/dispute/commands.ts (4)

54-61: Use countDocuments() instead of deprecated count().

Mongoose 6.x deprecated Model.count() in favor of countDocuments(). Since this project uses mongoose 6.13.6, update these calls to avoid deprecation warnings.

Proposed fix
       const buyerDisputes =
-        (await Dispute.count({
+        (await Dispute.countDocuments({
           $or: [{ buyer_id: buyer._id }, { seller_id: buyer._id }],
         })) + 1;
       const sellerDisputes =
-        (await Dispute.count({
+        (await Dispute.countDocuments({
           $or: [{ buyer_id: seller._id }, { seller_id: seller._id }],
         })) + 1;

38-38: Use strict equality or .equals() for ObjectId comparison.

Comparing Mongoose ObjectIds with == may produce unexpected results. Prefer === with .toString() or use the .equals() method for reliable comparison.

Proposed fix
-    if (user._id == order.buyer_id) initiator = 'buyer';
+    if (user._id.equals(order.buyer_id)) initiator = 'buyer';

23-23: Minor typo in variable name.

disputStartWindow is missing an 'e' — should be disputeStartWindow for clarity.

Proposed fix
-    const disputStartWindow = process.env.DISPUTE_START_WINDOW;
-    if (disputStartWindow === undefined)
+    const disputeStartWindow = process.env.DISPUTE_START_WINDOW;
+    if (disputeStartWindow === undefined)
       throw new Error('DISPUTE_START_WINDOW environment variable not defined');
-    const secsUntilDispute = parseInt(disputStartWindow);
+    const secsUntilDispute = parseInt(disputeStartWindow);

108-114: Inconsistent ObjectId query pattern — consider removing .toString().

The query uses user._id.toString() for comparisons, but throughout the codebase (bot/validations.ts, bot/start.ts, bot/ordersActions.ts, and bot/modules/orders/takeOrder.ts) all other seller_id and buyer_id queries use user._id directly without conversion. For consistency, remove the explicit string conversion.

Suggested change
     const orders = await Order.find({
       $or: [
-        { seller_id: user._id.toString() },
-        { buyer_id: user._id.toString() },
+        { seller_id: user._id },
+        { buyer_id: user._id },
       ],
       status: { $in: ['ACTIVE', 'FIAT_SENT'] },
     });

Comment thread bot/modules/dispute/commands.ts Outdated

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
bot/modules/dispute/commands.ts (1)

54-61: ⚠️ Potential issue | 🟡 Minor

Replace deprecated Model.count() with countDocuments().

Mongoose 6.x deprecated Model.count() in favor of countDocuments(). Since both usages here include filters, use countDocuments() to avoid deprecation warnings.

Proposed fix
       const buyerDisputes =
-        (await Dispute.count({
+        (await Dispute.countDocuments({
           $or: [{ buyer_id: buyer._id }, { seller_id: buyer._id }],
         })) + 1;
       const sellerDisputes =
-        (await Dispute.count({
+        (await Dispute.countDocuments({
           $or: [{ buyer_id: seller._id }, { seller_id: seller._id }],
         })) + 1;
🧹 Nitpick comments (1)
bot/modules/dispute/commands.ts (1)

23-26: Typo in variable name: disputStartWindowdisputeStartWindow.

Minor typo - missing 'e' in the variable name.

Proposed fix
-    const disputStartWindow = process.env.DISPUTE_START_WINDOW;
-    if (disputStartWindow === undefined)
+    const disputeStartWindow = process.env.DISPUTE_START_WINDOW;
+    if (disputeStartWindow === undefined)
       throw new Error('DISPUTE_START_WINDOW environment variable not defined');
-    const secsUntilDispute = parseInt(disputStartWindow);
+    const secsUntilDispute = parseInt(disputeStartWindow);

@Luquitasjeffrey Luquitasjeffrey left a comment

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.

ACK!

@mostronatorcoder mostronatorcoder Bot left a comment

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.

Review: Interactive wizard for /dispute
Overall Assessment
The feature is useful — letting users pick orders via buttons instead of memorizing order IDs reduces friction. However, there are several issues that need addressing before this can be merged.

🔴 Security: No authorization check on initiateDispute callback
In actions.ts, the initiateDispute handler extracts the order ID from the callback data and calls handleDispute() directly. But it does not verify that the user pressing the button is the same user who received the order list.

Telegram inline keyboard callbacks can technically be triggered by any user in certain scenarios (e.g., if the message is in a group or forwarded). While handleDispute internally validates the user against the order's buyer/seller, the ctx.deleteMessage() call happens BEFORE that validation. An unauthorized user pressing the button would delete someone else's message.

Recommendation: Move ctx.deleteMessage() after handleDispute() succeeds, or wrap it in a check that confirms the callback user matches the original message recipient.

🔴 Missing PENDING status in the order query
The dispute function queries orders with status ACTIVE or FIAT_SENT:

status: { $in: ['ACTIVE', 'FIAT_SENT'] },
But looking at validateDisputeOrder (which handleDispute calls), it allows disputes on orders with status ACTIVE, FIAT_SENT, and PENDING (among others depending on the validation logic). If a user has a stuck order in another disputable status, they won't see it in the button list but could still dispute it via /dispute . The interactive wizard should show the same orders that are actually disputable.

Recommendation: Align the status filter with whatever validateDisputeOrder accepts, or better yet, extract the disputable statuses into a shared constant.

🟡 Order display is too terse
The button text format is:

${order._id.toString().substring(0, 2)}..${order._id.toString().slice(-2)} - ${order.type} - ${order.fiat_code} ${order.fiat_amount}
This shows de..82 - sell - USD 100. For a user with multiple orders in the same currency and type, this is ambiguous. The first/last 2 chars of a MongoDB ObjectId are not unique enough to differentiate.

Recommendation: Show more context — at minimum the counterparty username and/or the creation date. Example:

sell 100 USD @counterparty (2h ago)
@mostronatorcoder

🟡 No pagination for users with many active orders
If a user has 20+ active orders (power traders in communities), the inline keyboard will have 20+ buttons. Telegram has limits on inline keyboard size (max 100 buttons, but UX degrades well before that).

Recommendation: Add a limit (e.g., 10 most recent) with a note that older orders can be disputed via /dispute .

🟡 Messy commit history
11 commits including:

"fixed bug in my implementation"
"fixing formatting"
"formatting"
"improved workarround"
Multiple merge commits from upstream
This should be squashed before merge. The PR description says "interactive wizard for /dispute" but the commit history tells a story of trial and error.

Recommendation: Squash to 1-2 clean commits before merging.

✅ What looks good
CI passes (lint, CodeQL, tests)
No conflicts with main
Localization done for 10 languages
The callback regex ^initiateDispute_([0-9a-f]{24})$ correctly validates MongoDB ObjectId format
Clean separation: handleDispute extracted as reusable, dispute wraps it with wizard logic
userMiddleware applied to the callback action
Verdict
Request Changes — The security concern with deleteMessage before authorization and the missing status alignment need fixing. The UX issues (terse display, no pagination) are also worth addressing but could be tracked as follow-up issues.

@Luquitasjeffrey

Copy link
Copy Markdown
Collaborator

@mostronatorcoder

🔴 Missing PENDING status in the order query

The dispute function queries orders with status ACTIVE or FIAT_SENT:

status: { $in: ['ACTIVE', 'FIAT_SENT'] },

But looking at validateDisputeOrder (which handleDispute calls), it allows disputes on orders with status ACTIVE, FIAT_SENT, and PENDING (among others depending on the validation logic). If a user has a stuck order in another disputable status, they won't see it in the button list but could still dispute it via /dispute <id>. The interactive wizard should show the same orders that are actually disputable.

Recommendation: Align the status filter with whatever validateDisputeOrder accepts, or better yet, extract the disputable statuses into a shared constant.

This makes no sense. Users cannot start a dispute on 'PENDING' orders, they can only dispute active orders (those with status 'FIAT_SENT' or 'ACTIVE').
The item will be dismissed

@Luquitasjeffrey

Copy link
Copy Markdown
Collaborator

@mostronatorcoder

🟡 Order display is too terse The button text format is:

${order._id.toString().substring(0, 2)}..${order._id.toString().slice(-2)} - ${order.type} - ${order.fiat_code} ${order.fiat_amount} This shows de..82 - sell - USD 100. For a user with multiple orders in the same currency and type, this is ambiguous. The first/last 2 chars of a MongoDB ObjectId are not unique enough to differentiate.

Recommendation: Show more context — at minimum the counterparty username and/or the creation date. Example:

sell 100 USD @counterparty (2h ago) @mostronatorcoder

🟡 No pagination for users with many active orders If a user has 20+ active orders (power traders in communities), the inline keyboard will have 20+ buttons. Telegram has limits on inline keyboard size (max 100 buttons, but UX degrades well before that).

This is not a realistic case, normally users have 1 or 2 active orders at the same time.
And the display is the same as used in other commands such as /fiatsent or /release
These 2 items will also be dismissed

@Luquitasjeffrey

Copy link
Copy Markdown
Collaborator

@mostronatorcoder

🔴 Security: No authorization check on initiateDispute callback In actions.ts, the initiateDispute handler extracts the order ID from the callback data and calls handleDispute() directly. But it does not verify that the user pressing the button is the same user who received the order list.

Telegram inline keyboard callbacks can technically be triggered by any user in certain scenarios (e.g., if the message is in a group or forwarded). While handleDispute internally validates the user against the order's buyer/seller, the ctx.deleteMessage() call happens BEFORE that validation. An unauthorized user pressing the button would delete someone else's message.

Recommendation: Move ctx.deleteMessage() after handleDispute() succeeds, or wrap it in a check that confirms the callback user matches the original message recipient.

we checked your suggestion, there is no way the user pressing the button is not the one who received the order list it as the bot only send the inline keyboard button to the user that executed the /dispute command. In the worst case scenario where the user just resent the message to an attacker, telegram does not allow forwarding buttons and
This item will also be dismissed

@Luquitasjeffrey Luquitasjeffrey left a comment

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.

ACK!

@mostronatorcoder

Copy link
Copy Markdown
Contributor

@Luquitasjeffrey Thanks for the detailed responses. Let me address each point:

🔴 Missing PENDING status

You're right — I reviewed validateDisputeOrder more carefully and confirm that disputes can only be initiated on orders with status ACTIVE or FIAT_SENT. A PENDING order has no counterparty yet, so there's nothing to dispute. My original review was incorrect on this point. Dismissed.

🟡 Order display / No pagination

Fair points — the display format is consistent with other commands like /fiatsent and /release, and in practice users rarely have more than 1-2 active orders simultaneously. These are not blockers for this PR. Dismissed.

🔴 Security: deleteMessage before authorization

I agree that in practice this is not exploitable because:

  1. The bot sends the inline keyboard only to the user who executed /dispute (private chat)
  2. Telegram does not allow forwarding inline keyboard buttons
  3. handleDispute() validates the user is buyer/seller of the order

The theoretical risk I flagged doesn't apply in Telegram's private chat context. Dismissed.


Given that all concerns have been addressed, I'll update my review to approve this PR.

@mostronatorcoder mostronatorcoder Bot left a comment

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.

All concerns from my previous review have been addressed by the maintainer's responses. The implementation is sound:

  • Security is handled by handleDispute() validation + Telegram's private chat model
  • Status filter correctly matches disputable statuses (ACTIVE, FIAT_SENT)
  • Display format is consistent with existing commands (/fiatsent, /release)
  • CI passes, no conflicts, localization complete

Approved ✅

@grunch
grunch merged commit 6fe0374 into lnp2pBot:main Mar 4, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants