Skip to content

Feature/capr 50 onboarding#122

Open
YC-5002 wants to merge 5 commits intodevelopfrom
feature/capr-50-onboarding
Open

Feature/capr 50 onboarding#122
YC-5002 wants to merge 5 commits intodevelopfrom
feature/capr-50-onboarding

Conversation

@YC-5002
Copy link

@YC-5002 YC-5002 commented Mar 13, 2026

Changed onboarding to setup + added some additional config

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Mar 13, 2026

Reviewer's Guide

Refactors bot error handling into reusable helpers, tightens telemetry serialization types, generalizes view message typing, and introduces a new in-memory, slash-command-driven guild setup/onboarding flow with verification UI and tests, replacing the older guild onboarding module.

Sequence diagram for member onboarding and verification flow

sequenceDiagram
    actor Member
    participant DiscordGuild as Discord_Guild
    participant Bot as Bot_SetupCog
    participant VerifyView as VerifyView

    Member->>DiscordGuild: Join guild
    DiscordGuild->>Bot: on_member_join(member)
    Bot->>Bot: _ensure_setup(guild_id)
    alt onboarding disabled or incomplete
        Bot-->>DiscordGuild: Return (no onboarding)
    else onboarding enabled and configured
        Bot->>Bot: _mark_pending(guild_id, member_id)
        Bot->>DiscordGuild: Send welcome message with VerifyView
        Bot->>VerifyView: Initialize(target_user_id, on_accept, on_timeout_callback)
        Bot->>Bot: _schedule_grace_period_check(guild_id, member_id)
        opt welcome_dm_enabled
            Bot->>Member: Send DM with onboarding hint
        end
    end

    Member->>DiscordGuild: Click Accept Rules button
    DiscordGuild->>VerifyView: accept(interaction)
    alt interaction.user.id != target_user_id
        VerifyView->>Member: Ephemeral error (not your button)
    else valid target user
        VerifyView->>Bot: _handle_accept(interaction, target_user_id)
        Bot->>DiscordGuild: Lookup member and roles
        Bot->>DiscordGuild: Add member_role
        Bot->>Bot: Update UserOnboardingState to verified
        Bot->>Bot: _cancel_grace_task(guild_id, user_id)
        Bot->>Member: Ephemeral success message
        Bot->>DiscordGuild: Send log message (verified)
        VerifyView->>DiscordGuild: Edit message to disable button
        VerifyView->>VerifyView: stop()
    end

    par Grace period enforcement task
        Bot->>Bot: _enforce_grace_period(guild_id, user_id)
        Bot->>Bot: sleep(grace_period_hours)
        Bot->>Bot: Check UserOnboardingState and config
        alt auto_kick_unverified and still pending
            Bot->>DiscordGuild: Kick member
            Bot->>DiscordGuild: Send log message (removed)
            Bot->>Bot: Set status to new
        else already verified or disabled
            Bot-->>Bot: Do nothing
        end
    and Verification view timeout
        VerifyView-->>Bot: on_timeout_callback(user_id)
        Bot->>Bot: _mark_timed_out(guild_id, user_id)
        Bot->>DiscordGuild: Send log message (timeout)
    end
Loading

Class diagram for new setup onboarding cog and related schemas

classDiagram
    direction LR

    class Bot {
        +_format_missing_permissions(permissions list~str~) str
        +_get_app_command_error_message(error app_commands.AppCommandError) str
        +_get_prefix_error_message(error commands.CommandError) str
        +on_tree_error(interaction discord.Interaction, error app_commands.AppCommandError) None
        +on_command_error(ctx commands.Context, error commands.CommandError) None
    }

    class Telemetry {
        +_serialize_value(value object) JsonValue
    }

    class JsonPrimitive {
        <<type_alias>>
    }

    class JsonValue {
        <<type_alias>>
    }

    class BaseView {
        +message discord.InteractionMessage | discord.Message | None
        +on_error(interaction discord.Interaction, error Exception, item ui.Item) None
    }

    class VerifyView {
        +target_user_id int
        -_on_accept(interaction discord.Interaction, target_user_id int) Awaitable~None~
        -_on_timeout_callback(target_user_id int) Awaitable~None~
        +__init__(target_user_id int, on_accept Callable, on_timeout_callback Callable, timeout float) None
        +accept(interaction discord.Interaction, _button ui.Button) None
        +on_timeout() None
    }

    class Setup {
        +bot commands.Bot
        +setup app_commands.Group
        -_setup_store dict~int, GuildSetupConfig~
        -_user_state_store dict~str, UserOnboardingState~
        -_grace_tasks dict~str, asyncio.Task~None~~
        +__init__(bot commands.Bot) None
        -_state_key(guild_id int, user_id int) str
        -_ensure_setup(guild_id int) GuildSetupConfig
        -_get_user_state(guild_id int, user_id int) UserOnboardingState
        -_cancel_grace_task(guild_id int, user_id int) None
        -_schedule_grace_period_check(guild_id int, user_id int) None
        -_get_bot_member(guild discord.Guild) discord.Member
        -_first_public_text_channel(guild discord.Guild) discord.TextChannel
        -_format_role_mentions(guild discord.Guild, role_ids list~int~) str
        -_format_channel_mention(guild discord.Guild, channel_id int) str
        -_missing_items(config GuildSetupConfig) list~str~
        -_build_setup_message(guild discord.Guild) str
        -_parse_role_ids(raw str, guild discord.Guild) list~int~
        -_send_log_message(guild discord.Guild, config GuildSetupConfig, message str) None
        -_mark_pending(guild_id int, user_id int) None
        -_mark_timed_out(guild_id int, user_id int) None
        -_enforce_grace_period(guild_id int, user_id int) None
        -_handle_accept(interaction discord.Interaction, target_user_id int) None
        +on_guild_join(guild discord.Guild) None
        +on_member_join(member discord.Member) None
        +setup_summary(interaction discord.Interaction) None
        +setup_roles(interaction discord.Interaction, admin_roles str, moderator_roles str, member_role discord.Role) None
        +setup_channels(interaction discord.Interaction, log_channel discord.TextChannel, announcement_channel discord.TextChannel, welcome_channel discord.TextChannel, support_channel discord.TextChannel) None
        +setup_onboarding(interaction discord.Interaction, enabled bool, welcome_dm_enabled bool, auto_kick_unverified bool, grace_period_hours int, log_events bool, rules_location str, message str) None
        +setup_reset(interaction discord.Interaction) None
    }

    class GuildSetupConfig {
        <<pydantic_model>>
        +enabled bool
        +admin_role_ids list~int~
        +moderator_role_ids list~int~
        +log_channel_id int
        +announcement_channel_id int
        +welcome_channel_id int
        +welcome_dm_enabled bool
        +auto_kick_unverified bool
        +grace_period_hours int
        +log_events bool
        +support_channel_id int
        +rules_location str
        +verification_acceptance str
        +member_role_id int
        +onboarding_message_template str
    }

    class UserOnboardingState {
        <<pydantic_model>>
        +status str
        +started_at_utc datetime
        +completed_at_utc datetime
        +attempts int
    }

    class Onboarding {
        <<alias_of_Setup>>
    }

    class setup_function {
        <<module_setup>>
        +setup(bot commands.Bot) None
    }

    VerifyView --|> BaseView
    Setup --> GuildSetupConfig : uses
    Setup --> UserOnboardingState : uses
    Setup --> VerifyView : creates
    Onboarding --|> Setup
    setup_function --> Setup
    Telemetry --> JsonValue
    JsonValue --> JsonPrimitive
Loading

State diagram for user onboarding lifecycle

stateDiagram-v2
    [*] --> new

    state "new" as new
    state "pending" as pending
    state "verified" as verified

    new --> pending: on_member_join

    pending --> verified: accept_rules_button
    pending --> new: view_timeout
    pending --> new: grace_period_kick
    pending --> new: setup_reset

    verified --> new: setup_reset

    new --> [*]
    verified --> [*]
Loading

File-Level Changes

Change Details Files
Centralize and expand user-facing error message generation for slash and prefix commands.
  • Add helpers to map common Discord command errors and custom UserFriendlyError into human-readable strings.
  • Use these helpers in on_tree_error and on_command_error to build embeds when an expected, user-facing error occurs.
  • Add consistent messaging for missing permissions, DM-only restrictions, and generic check failures for both slash and prefix commands.
capy_discord/bot.py
Tighten telemetry value serialization to a JSON-safe type system.
  • Introduce JsonPrimitive and JsonValue type aliases representing JSON-safe values.
  • Change _serialize_value signature to accept object and return JsonValue.
  • Ensure lists and dicts are recursively serialized while forcing dict keys to strings and falling back to str(value) for unsupported types.
capy_discord/exts/core/telemetry.py
Allow BaseView to track either interaction or standard messages.
  • Widen BaseView.message type hint to support both discord.InteractionMessage and discord.Message instances to cover views sent via channel messages.
capy_discord/ui/views.py
Add a new setup/onboarding cog providing in-memory guild configuration, onboarding flows, and enforcement logic.
  • Introduce Setup/Onboarding cog with in-memory stores for per-guild setup, per-user onboarding state, and grace-period tasks attached to the bot instance.
  • Implement helpers to derive bot member, find first public text channel, format roles/channels, compute missing setup items, parse role IDs, and send optional onboarding log messages.
  • On guild join, post a setup checklist and guidance to the first public text channel.
  • On member join, if onboarding is enabled and configured, send a welcome/onboarding message with a VerifyView, mark the user as pending, optionally DM them, and schedule a grace-period enforcement task.
  • Implement grace-period enforcement that can kick unverified users after a configurable delay, with permission/role-hierarchy checks and logging.
  • Provide /setup slash subcommands (summary, roles, channels, config, reset) to manage role/channel bindings, onboarding behavior, and to reset all state for a guild.
  • Expose Onboarding alias and an async setup() entrypoint for cog registration.
capy_discord/exts/setup/setup.py
Add a reusable verification button view for onboarding acceptance flow.
  • Create VerifyView that extends BaseView, holds target_user_id, and exposes an 'Accept Rules' button wired to a provided on_accept callback.
  • Restrict button usage to the target user only and disable controls/update message after successful acceptance.
  • On timeout, call a provided callback to mark state as timed out and then delegate to BaseView.on_timeout.
capy_discord/exts/setup/_views.py
Define Pydantic schemas for guild setup config and per-user onboarding state.
  • Add GuildSetupConfig model capturing roles, channels, onboarding behavior flags, logging options, and message templates.
  • Add UserOnboardingState model tracking status, timestamps, and attempt count for a user within a guild.
capy_discord/exts/setup/_schemas.py
Introduce tests for the new setup/onboarding behavior.
  • Add fixtures for a mocked Bot and Onboarding cog.
  • Test guild join setup message posting behavior and channel selection logic.
  • Test member-join onboarding behavior with incomplete vs configured setup, including pending state and welcome message.
  • Test /setup roles and /setup config handlers updating GuildSetupConfig.
  • Test _handle_accept assigning roles and marking user verified, including response behavior.
  • Test logging helper skipping when logging disabled and grace-period enforcement kicking overdue, unverified members.
tests/capy_discord/exts/test_setup.py
Add package init for the new setup extension and remove legacy guild onboarding package.
  • Create empty init to define capy_discord.exts.setup as a package.
  • Delete old guild onboarding schemas and guild module that this new setup extension replaces.
capy_discord/exts/setup/__init__.py
capy_discord/guild/_schemas.py
capy_discord/guild/guild.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In Telemetry._serialize_value, the new fallback return str(value) changes previous behavior for any values that were already JSON-serializable but not explicitly handled (e.g. some custom types); consider limiting stringification to known-non-JSON-serializable types or documenting this behavioral change to avoid surprising downstream consumers.
  • In VerifyView.on_timeout, the buttons are not disabled or the message updated (unlike in accept), so the UI will continue to look interactive after timeout; consider calling disable_all_items() and editing the message to reflect the expired state before or after invoking the timeout callback.
  • In test_enforce_grace_period_kicks_unverified_member, the monkeypatch.setattr target (capy_discord.exts.onboarding.onboarding.asyncio.sleep) does not match the new module path (capy_discord.exts.setup.setup), so the fake sleep may never be applied; update the patch target to the actual module where _enforce_grace_period resolves asyncio.sleep.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `Telemetry._serialize_value`, the new fallback `return str(value)` changes previous behavior for any values that were already JSON-serializable but not explicitly handled (e.g. some custom types); consider limiting stringification to known-non-JSON-serializable types or documenting this behavioral change to avoid surprising downstream consumers.
- In `VerifyView.on_timeout`, the buttons are not disabled or the message updated (unlike in `accept`), so the UI will continue to look interactive after timeout; consider calling `disable_all_items()` and editing the message to reflect the expired state before or after invoking the timeout callback.
- In `test_enforce_grace_period_kicks_unverified_member`, the `monkeypatch.setattr` target (`capy_discord.exts.onboarding.onboarding.asyncio.sleep`) does not match the new module path (`capy_discord.exts.setup.setup`), so the fake sleep may never be applied; update the patch target to the actual module where `_enforce_grace_period` resolves `asyncio.sleep`.

## Individual Comments

### Comment 1
<location path="capy_discord/exts/core/telemetry.py" line_range="561-564" />
<code_context>
+    type JsonPrimitive = str | int | float | bool | None
+    type JsonValue = JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"]
+
+    def _serialize_value(self, value: object) -> JsonValue:
         """Convert complex Discord objects to simple serializable types.

</code_context>
<issue_to_address>
**issue (bug_risk):** Serializing all non-discord/collection values via `str(value)` changes types and may break telemetry consumers.

This change will now stringify primitives that previously remained JSON primitives, so downstream may receive values like "true" and "123" instead of `true` and `123`. To avoid changing telemetry semantics while still handling complex objects and keeping the new `JsonValue` typing, consider preserving native primitives first:

```python
if isinstance(value, (str, int, float, bool)) or value is None:
    return value

# discord / collection / dict branches...

return str(value)
```
</issue_to_address>

### Comment 2
<location path="tests/capy_discord/exts/test_setup.py" line_range="262" />
<code_context>
+    async def fake_sleep(_seconds: float) -> None:
+        state.started_at_utc = utc_now() - timedelta(hours=2)
+
+    monkeypatch.setattr("capy_discord.exts.onboarding.onboarding.asyncio.sleep", fake_sleep)
+
+    await cog._enforce_grace_period(guild.id, member.id)
</code_context>
<issue_to_address>
**issue (testing):** The monkeypatch target for asyncio.sleep is incorrect, so this test will not behave as intended.

The implementation under test is in `capy_discord.exts.setup.setup`, but this test patches `capy_discord.exts.onboarding.onboarding.asyncio.sleep`, so the `asyncio.sleep` call in `_enforce_grace_period` is never patched. That means the test may actually sleep for up to an hour instead of simulating time passage. Please update the monkeypatch target to the module where `_enforce_grace_period` (and its `utc_now`/`asyncio.sleep` usage) is defined, e.g. `"capy_discord.exts.setup.setup.asyncio.sleep"`, so the grace-period logic is exercised deterministically.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +561 to 564
def _serialize_value(self, value: object) -> JsonValue:
"""Convert complex Discord objects to simple serializable types.

Args:
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Serializing all non-discord/collection values via str(value) changes types and may break telemetry consumers.

This change will now stringify primitives that previously remained JSON primitives, so downstream may receive values like "true" and "123" instead of true and 123. To avoid changing telemetry semantics while still handling complex objects and keeping the new JsonValue typing, consider preserving native primitives first:

if isinstance(value, (str, int, float, bool)) or value is None:
    return value

# discord / collection / dict branches...

return str(value)

async def fake_sleep(_seconds: float) -> None:
state.started_at_utc = utc_now() - timedelta(hours=2)

monkeypatch.setattr("capy_discord.exts.onboarding.onboarding.asyncio.sleep", fake_sleep)
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (testing): The monkeypatch target for asyncio.sleep is incorrect, so this test will not behave as intended.

The implementation under test is in capy_discord.exts.setup.setup, but this test patches capy_discord.exts.onboarding.onboarding.asyncio.sleep, so the asyncio.sleep call in _enforce_grace_period is never patched. That means the test may actually sleep for up to an hour instead of simulating time passage. Please update the monkeypatch target to the module where _enforce_grace_period (and its utc_now/asyncio.sleep usage) is defined, e.g. "capy_discord.exts.setup.setup.asyncio.sleep", so the grace-period logic is exercised deterministically.

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.

2 participants