Skip to content

feat: better commands info (help + menu)#109

Open
toto04 wants to merge 3 commits intomainfrom
commands-help
Open

feat: better commands info (help + menu)#109
toto04 wants to merge 3 commits intomainfrom
commands-help

Conversation

@toto04
Copy link
Copy Markdown
Contributor

@toto04 toto04 commented Apr 13, 2026

Command menus (set with setMyCommands) are cached and per-user, with global defaults
/help output is generated each time using the same permissions checks

Caution

Might have broken permissions checks on execution - MUST CHECK THOROUGHLY

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

Walkthrough

The pull request introduces a Redis-backed deduplication system for user command caching, implements role-based caching with fallback adapter, refactors the managed-commands system with improved permission checking and scope-based command filtering, updates command type signatures to remove redundant context parameters, and adds utility functions for async array operations.

Changes

Cohort / File(s) Summary
Redis & Caching Infrastructure
src/redis/set.ts, src/utils/once.ts
New RedisSet class for Redis-backed set operations with TTL support and in-memory fallback; updated once utility to consistently return asynchronously resolved cached values via Awaiter.
Command Type System
src/lib/managed-commands/command.ts
Added command scope type guards (isAllowedInPrivate, isAllowedEverywhere), AnyGroupCommand type alias, toBotCommands converter, isForThisScope scope matcher, and switchOnScope handler selector for scope-specific command behavior.
Managed Commands Core
src/lib/managed-commands/index.ts
Simplified getUserRoles and overrideGroupAdminCheck signatures (removed CommandContext parameter), introduced cachedUserSetCommands hook to skip menu regeneration, refactored command-menu generation with global "free" commands and per-user scoped commands, implemented cached permission helpers, and enhanced /help output with scope indicators and admin-restricted command markers.
Command Implementation & Application
src/commands/index.ts
Implemented cachedUserSetCommands Redis-backed deduplication hook, introduced userRolesCache with 5-minute TTL using RedisFallbackAdapter, updated getUserRoles to read from cache first with fallback to API, reordered ping/start command definitions.
Utility Functions
src/utils/arrays.ts, src/utils/types.ts
Added asyncFilter and asyncMap for parallel async array operations; improved ContextWith type signature to accept full context type and subset of keys, making specified keys non-nullable while preserving all properties.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant ManagedCommands
    participant Redis as Redis<br/>(cachedUserSetCommands)
    participant RolesCache as Redis<br/>(userRolesCache)
    participant API as api.tg.permissions
    participant Telegram as Telegram Bot API

    Client->>ManagedCommands: Message received (user in group)
    ManagedCommands->>Redis: cachedUserSetCommands(userId, chatId)
    
    alt Cache hit (returns true)
        Redis-->>ManagedCommands: true
        ManagedCommands->>Telegram: Skip command menu update
    else Cache miss (returns false)
        Redis-->>ManagedCommands: false
        ManagedCommands->>RolesCache: get(userId)
        
        alt Roles cached
            RolesCache-->>ManagedCommands: roles[]
        else Roles not cached
            RolesCache->>API: getRoles(userId)
            API-->>RolesCache: roles[]
            RolesCache->>RolesCache: cache for 5 min
        end
        
        ManagedCommands->>ManagedCommands: Filter commands by scope & roles
        ManagedCommands->>Telegram: setMyCommands(scope: chat_member, userId, chatId)
        Telegram-->>ManagedCommands: ✓
        ManagedCommands->>Redis: Mark as cached
    end
    
    ManagedCommands-->>Client: Command available
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: better commands info (help + menu)' directly corresponds to the PR objectives and changes, which focus on improving command help output and command menu caching/per-user customization.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

@toto04
Copy link
Copy Markdown
Contributor Author

toto04 commented Apr 13, 2026

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

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

⚠️ Outside diff range comments (1)
src/lib/managed-commands/index.ts (1)

419-427: ⚠️ Potential issue | 🟠 Major

/help <command> is bypassing the new permission filter.

This branch searches this.getCommands() directly and returns usage even for commands the caller could not execute or see in the normal /help list. That leaks hidden/admin-only commands and breaks the "same permissions checks as elsewhere" goal.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/managed-commands/index.ts` around lines 419 - 427, The /help
<command> branch is querying this.getCommands() directly and can return usage
for commands the caller shouldn't see; replace the direct this.getCommands()
lookup with the same permission-filtered command list used by the normal help
flow (i.e., apply the existing permission-check helper or the filtered-getter
the codebase uses instead of this.getCommands()) before finding the command,
then call ManagedCommands.formatCommandUsage(cmd) and ctx.reply as before; in
short, perform the same permission filtering when resolving the specific command
as is used elsewhere so hidden/admin-only commands are not returned.
🧹 Nitpick comments (1)
src/utils/arrays.ts (1)

7-9: Consider bounded concurrency for large inputs.

Promise.all(arr.map(...)) launches all tasks at once. For large arrays, this can create avoidable memory and downstream API/DB pressure. Consider adding a concurrency-limited variant (or optional concurrency param) for safer reuse.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/arrays.ts` around lines 7 - 9, The current asyncMap function
launches all promises at once causing potential memory/IO pressure; update
asyncMap to support bounded concurrency by adding an optional concurrency
parameter (e.g., asyncMap<T,U>(arr: T[], mapper: (item:T)=>Promise<U>,
concurrency?: number)) or create a new asyncMapWithConcurrency helper that runs
at most N mapper tasks concurrently; implement this by iterating the array and
scheduling tasks through a simple semaphore/worker queue or using a tiny
internal pool (driven by the provided concurrency value, defaulting to Infinity
to preserve current behavior), ensure the returned Promise resolves to U[] in
original order and reference the existing asyncMap symbol so callers can switch
to the concurrency-aware variant.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/managed-commands/index.ts`:
- Around line 524-535: The group-only permission block is being applied for
commands with scope "both" even in private chats; update the logic in the
permission check so that when the current chat is private (check ctx.chat.type
or equivalent), you skip the isAllowedInGroups branch entirely — i.e., only run
isAllowedInGroups(command) && isGroupChat check before calling
this.isCommandAllowedInGroup(command, ctx.chat.id) and evaluating
allowGroupAdmins via isFromGroupAdmin(); otherwise, directly proceed to
getUserRoles() and this.isCommandAllowedForRoles(command, roles).
- Around line 403-410: The cache check using
this.hooks.cachedUserSetCommands?.(ctx.from.id, ctx.chat.id) is done before
calling ctx.api.setMyCommands and any API failure is swallowed, so you must only
mark the user:chat as cached after setMyCommands completes successfully: keep
the initial check to skip if already cached, but move the code that records/sets
the cache (the hook or setter you have for marking a user/chat as cached) into
the successful path of await
ctx.api.setMyCommands(allowedCommands.flatMap(toBotCommands), { scope: { type:
"chat_member", chat_id: ctx.chat.id, user_id: ctx.from.id } }) — do not set the
cache in the .catch handler and ensure the .catch still logs or surfaces errors
as needed so transient Telegram failures won’t incorrectly mark the pair as
cached; use getAllowedCommandsFor, toBotCommands and the hooks.* methods to
locate the exact lines to change.

In `@src/redis/set.ts`:
- Around line 59-65: flushMemoryCache() is calling .map() on
Set.prototype.values() (an iterator), causing TypeError; fix by converting the
iterators to arrays before mapping (e.g., use
Array.from(this.memoryCache.values()) or [...this.memoryCache]) and do the same
for this.deletions so Promise.all receives an array of promises; apply the
identical change to the mirrored methods in
src/lib/redis-fallback-adapter/index.ts (the calls at the corresponding lines).
- Around line 78-82: The _add method currently uses sAdd(this.prefix, value) and
then expire(this.prefix, this.options.ttl), which applies TTL to the entire set
not individual members; change the data model and update the implementation so
TTL is per-member — either store each member as its own key with setex (use a
namespaced key derived from this.prefix and the member value, and setex using
this.options.ttl inside add/_add) or switch to a sorted set (use zAdd with
score=Date.now() and implement eviction via zRemRangeByScore based on now - ttl
in your add/_add and any lookup methods). Update any methods that read the set
(e.g., the public add, get, has helpers) to use the new structure (per-member
keys or sorted-set eviction) so the 24h staleness bound is enforced per entry
rather than by expiring the whole set.

In `@src/utils/once.ts`:
- Around line 22-27: The wrapper returned by the once helper currently sets
called = true then awaits fn(...args) but never rejects result if fn throws,
causing later callers to hang; change the invocation to catch errors and reject
the stored promise: when the returned async function is invoked, call
fn(...args) inside a try/catch, on success call result.resolve(value) and on
failure call result.reject(error) (and rethrow or return the rejection) so that
result is always settled; keep the existing called, result, and fn identifiers
and ensure result.reject is invoked in the catch branch.

---

Outside diff comments:
In `@src/lib/managed-commands/index.ts`:
- Around line 419-427: The /help <command> branch is querying this.getCommands()
directly and can return usage for commands the caller shouldn't see; replace the
direct this.getCommands() lookup with the same permission-filtered command list
used by the normal help flow (i.e., apply the existing permission-check helper
or the filtered-getter the codebase uses instead of this.getCommands()) before
finding the command, then call ManagedCommands.formatCommandUsage(cmd) and
ctx.reply as before; in short, perform the same permission filtering when
resolving the specific command as is used elsewhere so hidden/admin-only
commands are not returned.

---

Nitpick comments:
In `@src/utils/arrays.ts`:
- Around line 7-9: The current asyncMap function launches all promises at once
causing potential memory/IO pressure; update asyncMap to support bounded
concurrency by adding an optional concurrency parameter (e.g.,
asyncMap<T,U>(arr: T[], mapper: (item:T)=>Promise<U>, concurrency?: number)) or
create a new asyncMapWithConcurrency helper that runs at most N mapper tasks
concurrently; implement this by iterating the array and scheduling tasks through
a simple semaphore/worker queue or using a tiny internal pool (driven by the
provided concurrency value, defaulting to Infinity to preserve current
behavior), ensure the returned Promise resolves to U[] in original order and
reference the existing asyncMap symbol so callers can switch to the
concurrency-aware variant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5a839325-e753-42ce-adc4-d61b490a3a24

📥 Commits

Reviewing files that changed from the base of the PR and between a4af4f3 and f4481d8.

📒 Files selected for processing (7)
  • src/commands/index.ts
  • src/lib/managed-commands/command.ts
  • src/lib/managed-commands/index.ts
  • src/redis/set.ts
  • src/utils/arrays.ts
  • src/utils/once.ts
  • src/utils/types.ts

Comment thread src/lib/managed-commands/index.ts
Comment on lines +524 to +535
if (isAllowedInGroups(command)) {
const allowed = this.isCommandAllowedInGroup(command, ctx.chat.id)
if (!allowed) return false

if (command.permissions.allowGroupAdmins) {
const isAdmin = await isFromGroupAdmin()
if (isAdmin) return true
}
}

const roles = await this.getUserRoles(ctx.from.id, ctx)

// blacklist is stronger than whitelist
if (allowedRoles?.every((r) => !roles.includes(r))) return false
if (excludedRoles?.some((r) => roles.includes(r))) return false
const roles = await getUserRoles()
return this.isCommandAllowedForRoles(command, roles)
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.

⚠️ Potential issue | 🟠 Major

Skip group-only permission logic when the current chat is private.

isAllowedInGroups(command) is also true for scope: "both", so this block still applies allowedGroupsId, excludedGroupsId, and allowGroupAdmins in private chats. That can incorrectly hide valid private commands and makes /help/execution diverge for commands that only use group-specific restrictions.

💡 Minimal fix
-    if (isAllowedInGroups(command)) {
+    if ((ctx.chat.type === "group" || ctx.chat.type === "supergroup") && isAllowedInGroups(command)) {
       const allowed = this.isCommandAllowedInGroup(command, ctx.chat.id)
       if (!allowed) return false

       if (command.permissions.allowGroupAdmins) {
         const isAdmin = await isFromGroupAdmin()
         if (isAdmin) return true
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/managed-commands/index.ts` around lines 524 - 535, The group-only
permission block is being applied for commands with scope "both" even in private
chats; update the logic in the permission check so that when the current chat is
private (check ctx.chat.type or equivalent), you skip the isAllowedInGroups
branch entirely — i.e., only run isAllowedInGroups(command) && isGroupChat check
before calling this.isCommandAllowedInGroup(command, ctx.chat.id) and evaluating
allowGroupAdmins via isFromGroupAdmin(); otherwise, directly proceed to
getUserRoles() and this.isCommandAllowedForRoles(command, roles).

Comment thread src/redis/set.ts
Comment thread src/redis/set.ts
Comment thread src/utils/once.ts
Comment on lines 22 to +27
return async (...args: A) => {
if (!called) {
called = true
result = await fn(...args)
result.resolve(await fn(...args))
}
return result
return await result
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.

⚠️ Potential issue | 🔴 Critical

A first-call rejection now deadlocks every later caller.

If fn(...args) throws or rejects at Line 25, called stays true but result is never resolved or rejected. After that, every invocation hangs on await result.

💡 Safer implementation
 export function once<R, A extends unknown[]>(fn: (...args: A) => MaybePromise<R>) {
-  const result: Awaiter<R> = new Awaiter()
-  let called = false
-
-  return async (...args: A) => {
-    if (!called) {
-      called = true
-      result.resolve(await fn(...args))
-    }
-    return await result
-  }
+  let result: Promise<Awaited<R>> | undefined
+
+  return (...args: A) => {
+    result ??= Promise.resolve(fn(...args))
+    return result
+  }
 }
📝 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
return async (...args: A) => {
if (!called) {
called = true
result = await fn(...args)
result.resolve(await fn(...args))
}
return result
return await result
export function once<R, A extends unknown[]>(fn: (...args: A) => MaybePromise<R>) {
let result: Promise<Awaited<R>> | undefined
return (...args: A) => {
result ??= Promise.resolve(fn(...args))
return result
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/once.ts` around lines 22 - 27, The wrapper returned by the once
helper currently sets called = true then awaits fn(...args) but never rejects
result if fn throws, causing later callers to hang; change the invocation to
catch errors and reject the stored promise: when the returned async function is
invoked, call fn(...args) inside a try/catch, on success call
result.resolve(value) and on failure call result.reject(error) (and rethrow or
return the rejection) so that result is always settled; keep the existing
called, result, and fn identifiers and ensure result.reject is invoked in the
catch branch.

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.

1 participant