Skip to content

feat(cli): expand Slack imports across conversation types - #1

Merged
RenKoya1 merged 3 commits into
RenKoya1:feat/slack-importfrom
nicknack5050:bumble/comprehensive-slack-import
Jul 30, 2026
Merged

feat(cli): expand Slack imports across conversation types#1
RenKoya1 merged 3 commits into
RenKoya1:feat/slack-importfrom
nicknack5050:bumble/comprehensive-slack-import

Conversation

@nicknack5050

Copy link
Copy Markdown

Summary

This is a focused extension of block/buzz#2704, built directly on its current head (d3a4a949). Merging this stacked PR into RenKoya1:feat/slack-import will update the existing Block/Buzz PR without duplicating its full history against main.

  • accept repeatable --export-dir roots and merge Slack/Slackdump channels.json, groups.json, dms.json, mpims.json, users.json, and org_users.json
  • preserve public/private visibility and archived state, including mapped private-stream membership without widening access
  • adopt pre-created Buzz channel shells through a validated CSV/JSON crosswalk, temporarily reopening archived channels for backfill and restoring them afterward
  • open DMs/MPIMs through Buzz's native DM flow only when every active human participant is mapped, the importer is one of those participants, and distinct Slack histories cannot collapse into one Buzz DM
  • retain renderable classic attachments, Block Kit rich text, bot/app posts, files, threads, reactions, and workspace-scoped provenance
  • expand the resume ledger and offline dry-run report with raw/skipped record accounting, conversation classes, existing-channel adoption, membership gaps, DM blockers, and reaction-use totals
  • document the workflow, safety gates, and remaining fidelity limits

Safety properties

  • A private Slack conversation is never created or repaired as an open Buzz channel.
  • Unmapped private members are reported, not substituted with another identity.
  • DMs/MPIMs are blocked before any write unless their immutable native participant set is complete and collision-free.
  • Duplicate conversation identities, unsafe directory names, conflicting timestamps, and invalid channel crosswalks fail closed.
  • Dry-run mode remains offline and does not write the state ledger.
  • No Lucid export data, channel crosswalk, credentials, or other workspace-private artifacts are included in this branch.

Validation

Exact commit: 42f2c33add4450bc479ba910b419180da909ac4b

  • cargo fmt --all -- --check
  • cargo test -p buzz-cli — 290 passed, 0 failed
  • cargo clippy -p buzz-cli --all-targets -- -D warnings
  • git diff --cached --check
  • offline dry run against separate Lucid public/private Slackdump roots plus the 658-channel crosswalk:
    • 658 conversations selected: 624 public, 34 private
    • all 658 existing Buzz channels adopted; zero channels created
    • 595 archived conversations planned for reopen/backfill/restore
    • 138,263 raw records; 132,023 importable messages
    • 6,232 system records and 8 edit/delete mutation records intentionally skipped
    • 6,202 reaction groups representing 6,983 source reaction uses
    • 170 mappable private memberships reported as missing identity mappings

The Lucid audit was dry-run only: it did not connect to the relay, write an import state file, or import Slack history.

Remaining limits

Slack-hosted files are linked but not re-hosted; same-emoji reactions remain one bot-signed reaction rather than preserving every reactor identity; edit/delete history is not reconstructed; and interactive Slack app actions, workflows, and custom emoji are not replayed.

Originating Buzz thread: buzz://message?channel=d9eedfbf-4b00-4ab2-a574-5859cd2c3d84&id=74baa551b197701ed487a0538796135998585d709cb2b0055b832f903cd54075

Merge separate Slackdump roots, preserve private conversation visibility, adopt existing channel shells, open native DMs safely, render richer message content, and expand resumable dry-run accounting.

Co-authored-by: nicknack5050 <nick@lucid.rocks>
Signed-off-by: nicknack5050 <nick@lucid.rocks>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b609de4-e373-4d49-a8c0-5dad735a49c3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@RenKoya1
RenKoya1 marked this pull request as ready for review July 29, 2026 07:43
@RenKoya1

RenKoya1 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Thank you so much @nicknack5050
Your gates reproduce on my side, and I like that the safety posture isn't decorative — visibility gets narrowed before history lands, unmapped people never get swapped for someone else, and the "thread root got filtered out, so promote the reply to top-level" call is the right one.

Two things I'd want fixed before merging.

The DM collision guard doesn't cover every case. dm_import_blockers (crates/buzz-cli/src/commands/import.rs:386-406) only feeds selected conversations into participant_sets. So if D1 went in on an earlier run and this run narrows down — --channels D2, or D1 dropped out of a refreshed export — nothing registers that participant set and D2 sails through.

The relay keys open_dm on the participant set (crates/buzz-relay/src/handlers/command_executor.rs:340-366), so D2 lands on D1's channel and the histories merge. Throwaway test with D1 in the ledger and only D2 selected:

SCRATCH blockers = {}

The fix wants to live somewhere selection can't reach. In ensure_dm, once the relay hands back the UUID and before any message goes out, bail if another Slack conversation in the ledger already owns it:

if let Some((other, _)) = self.state.channels.iter()
    .find(|(id, s)| id.as_str() != channel.id && s.uuid == uuid.to_string())
{
    return Err(CliError::Usage(format!(
        "Slack {} {} resolves to the Buzz DM already imported for {other}; \
         refusing to merge separate histories",
        channel.kind.as_str(), channel.id
    )));
}

importer.rs:916 hardcodes Kind::Custom(41010). buzz_sdk::build_dm_open() already exists and pulls from KIND_DM_OPEN (crates/buzz-core/src/kind.rs:474); AGENTS.md is firm that kind integers live there and nowhere else. Hand-rolling it also skips the SDK's check_pubkey_hex and 1–8 participant guard — no live impact since every key came through parse_pubkey, but a layer gone for nothing. build_dm_open(&refs)?.tags(extra_tags) keeps the d / import / import_conversation tags as-is.


Two I'd like fixed but wouldn't hold the PR over:

Adopted channels can be archived in Buzz while Slack thinks they're active. The unarchive at importer.rs:201-219 is gated on channel.is_archived, but --channel-map adopts channels whose archive state is independent of Slack's. When they disagree every write bounces and the run dies at MAX_CONSECUTIVE_FAILURES with "5 consecutive submit failures", which points nowhere useful. Simplest is to attempt the unarchive on first adoption regardless of the flag and treat "wasn't archived anyway" as success.

channels.json is required in every root (export.rs:414) while groups.json / dms.json / mpims.json go through read_optional_json. Multi-root merging exists for Slackdump's split passes, so a private-or-DM-only root fails hard for no reason. The channels.is_empty() backstop at export.rs:474 already covers it, so making it optional looks safe.


Smaller stuff, take or leave:

The previous != buzz_id branch in mapping.rs:62-75 is unreachable — an exact duplicate row trips the buzz_ids check first and surfaces as "assigns Buzz UUID X more than once", which is a puzzling thing to read when you've just got a repeated line.

In the CSV parser (mapping.rs:159-172), '"' if field.is_empty() means a quoted field with leading whitespace ( "a,b") is treated as unquoted and splits on the comma; a bare \r with no \n after it ends up inside the field. Fine for generated crosswalks, but a .trim() or a comment naming the assumption would save the next person some time.

importer.rs:743 doesn't escape the display name in format!("**{author_name}**: ..."), so anyone with * or _ in their Slack name gets mangled rendering on every message they wrote.

importer.rs is 1302 lines, export.rs 1231, import.rs 1097. No Rust guard in CI, but desktop/web/mobile all cap at 1000. render_slack_block / render_attachment feel like a render.rs, the DM path like a dm.rs.

If a run errors out mid-channel, an adopted archived channel stays unarchived until the next run puts it back. Correct behaviour, just worth a line in docs/slack-import.md so nobody panics when they find one.


Get the DM guard and the kind constant sorted and I'll take it. The other two can be a follow-up.

Co-authored-by: nicknack5050 <nick@lucid.rocks>
Signed-off-by: nicknack5050 <nick@lucid.rocks>
@nicknack5050

Copy link
Copy Markdown
Author

Addressed the review feedback in 6385727767447f9b22ea8e15c574f769910425e0.

  • Added a ledger-wide native-DM UUID collision guard after relay resolution (and on resume), with a regression for D1 already recorded and D2 resolving to the same Buzz DM.
  • Switched DM-open construction to buzz_sdk::build_dm_open while retaining import provenance tags.
  • First-time adopted channels now attempt unarchive regardless of Slack's archived flag; only the relay's exact “channel is not archived” result is accepted as a no-op.
  • Made channels.json optional per split export root while preserving the overall no-conversations rejection.
  • Corrected duplicate Slack-ID diagnostics before Buzz target-UUID reuse checks.
  • Hardened CSV parsing for whitespace before quoted fields and bare-CR records.
  • Escaped Markdown metacharacters in rendered author prefixes.
  • Documented the interrupted adopted-channel reopen/restore behavior.

I left the optional file-splitting suggestion unchanged in this correctness pass so the review fix does not add a broad structural refactor; it can be a focused follow-up.

Verified at the exact commit above:

  • cargo test -p buzz-cli: 296 passed
  • cargo clippy -p buzz-cli --all-targets -- -D warnings: passed
  • cargo fmt --all -- --check: passed

@nicknack5050

Copy link
Copy Markdown
Author

Addressed the review feedback in 6385727.\n\n- Added a ledger-wide native-DM UUID collision guard after relay resolution (and on resume), with a regression for D1 already recorded and D2 resolving to the same Buzz DM.\n- Switched DM-open construction to while retaining import provenance tags.\n- First-time adopted channels now attempt unarchive regardless of Slack's archived flag; only the relay's exact “channel is not archived” result is accepted as a no-op.\n- Made optional per split export root while preserving the overall no-conversations rejection.\n- Corrected duplicate Slack-ID diagnostics before Buzz target-UUID reuse checks.\n- Hardened CSV parsing for whitespace before quoted fields and bare-CR records.\n- Escaped Markdown metacharacters in rendered author prefixes.\n- Documented the interrupted adopted-channel reopen/restore behavior.\n\nI left the optional file-splitting suggestion unchanged in this correctness pass so the review fix does not add a broad structural refactor; it can be a focused follow-up.\n\nVerified at the exact commit above:\n\n-
running 296 tests
test client::media_download_tests::legacy_upload_retry_statuses_are_narrow ... ok
test client::media_download_tests::media_url_from_sha_uses_relay_media_path ... ok
test client::media_download_tests::media_url_accepts_only_same_relay_media_urls ... ok
test client::media_download_tests::media_url_rejects_path_confusion_and_non_hash_inputs ... ok
test agent_management::tests::create_rejects_invalid_channel ... ok
test agent_management::tests::update_requires_a_change ... ok
test client::media_download_tests::media_get_auth_header_is_server_scoped ... ok
test client::retry_policy_tests::moderation_kind_502_returns_delivery_unknown ... ok
test client::retry_policy_tests::query_403_is_not_retried ... ok
test client::retry_policy_tests::moderation_kind_non_ingest_429_returns_delivery_unknown ... ok
test agent_management::tests::create_is_owner_encrypted_and_matches_desktop_contract ... ok
test client::retry_policy_tests::exhausted_ingest_429_returns_relay_429_retryable ... ok
test client::retry_policy_tests::query_502_is_retried_then_succeeds ... ok
test client::retry_policy_tests::stored_event_all_502s_return_delivery_unknown ... ok
test client::retry_policy_tests::stored_event_502_is_retried_under_standard_policy ... ok
test client::retry_tests::env_duration_secs_parsing ... ok
test client::retry_tests::hint_text_empty_returns_none ... ok
test client::retry_tests::hint_text_plain_extracted_field_returns_secs ... ok
test client::retry_tests::hint_text_plain_no_pattern_returns_none ... ok
test client::retry_tests::hint_text_raw_json_body_returns_secs ... ok
test client::retry_tests::jitter_stays_within_base ... ok
test client::retry_tests::moderation_kind_covers_9040_through_9044 ... ok
test client::retry_tests::non_moderation_kinds_are_not_moderation ... ok
test client::retry_tests::parse_empty_body_returns_none ... ok
test client::retry_tests::parse_garbled_body_returns_none ... ok
test client::retry_tests::parse_missing_retry_pattern_returns_none ... ok
test client::retry_tests::parse_relay_json_with_error_field ... ok
test client::retry_tests::parse_relay_json_with_message_field ... ok
test client::retry_tests::parse_retry_in_zero_seconds ... ok
test client::retry_tests::retry_constants_are_sensible ... ok
test client::tests::create_response_with_id_overrides_local_id_with_relay_id ... ok
test client::tests::extract_relay_response_field_reads_response_message_json ... ok
test client::tests::extract_relay_response_field_returns_none_for_non_response_message ... ok
test client::tests::query_cursor_rejects_missing_or_malformed_sort_key ... ok
test client::tests::query_cursor_uses_last_events_composite_sort_key ... ok
test client::tests::sign_event_unchecked_does_not_inject_ambient_auth_tag ... ok
test client::tests::sign_event_unchecked_preserves_callers_content_auth_tag ... ok
test client::tests::with_auth_tag_omits_header_when_not_configured ... ok
test client::tests::with_auth_tag_sets_header_when_configured ... ok
test commands::agents::tests::archived_non_hex_p_tag_dropped ... ok
test commands::agents::tests::archived_short_p_tag_dropped ... ok
test commands::agents::tests::archived_state2_empty_p_tags_returns_empty ... ok
test commands::agents::tests::archived_state2_valid_event_returns_pubkeys ... ok
test commands::agents::tests::archived_state3_duplicate_nip70_tags_errors ... ok
test commands::agents::tests::archived_state3_exact_marker_plus_malformed_marker_errors ... ok
test commands::agents::tests::archived_state3_lone_malformed_nip70_tag_errors ... ok
test commands::agents::tests::archived_state3_no_nip70_tag_errors ... ok
test commands::agents::tests::archived_state3_wrong_author_errors ... ok
test commands::agents::tests::archived_state3_wrong_kind_errors ... ok
test commands::agents::tests::archived_uppercase_self_matches_lowercase_event_author ... ok
test commands::agents::tests::auth_selection_case_insensitive_owner_match ... ok
test commands::agents::tests::auth_selection_malformed_five_elements_returns_none ... ok
test commands::agents::tests::auth_selection_malformed_non_hex_owner_returns_none ... ok
test commands::agents::tests::auth_selection_malformed_non_hex_sig_returns_none ... ok
test commands::agents::tests::auth_selection_malformed_short_sig_returns_none ... ok
test commands::agents::tests::auth_selection_malformed_three_elements_returns_none ... ok
test commands::agents::tests::auth_selection_no_tags_returns_none ... ok
test commands::agents::tests::auth_selection_non_array_tag_skipped ... ok
test commands::agents::tests::auth_selection_non_owner_returns_none ... ok
test commands::agents::tests::auth_selection_non_string_elements_returns_none ... ok
test commands::agents::tests::auth_selection_owner_match_returns_tag ... ok
test commands::agents::tests::auth_selection_valid_plus_duplicate_auth_tag_returns_none ... ok
test commands::agents::tests::auth_selection_valid_plus_malformed_second_auth_tag_returns_none ... ok
test commands::agents::tests::auth_selection_wrong_label_returns_none ... ok
test commands::agents::tests::normalize_self_lowercases_uppercase_hex ... ok
test commands::agents::tests::normalize_self_rejects_non_hex ... ok
test commands::agents::tests::normalize_self_rejects_wrong_length ... ok
test commands::channel_templates::tests::find_template_matches_case_insensitive ... ok
test commands::channel_templates::tests::find_template_missing_store_is_not_found ... ok
test commands::channel_templates::tests::find_template_not_found_lists_available_names ... ok
test commands::channel_templates::tests::load_templates_parses_full_roster ... ok
test commands::channel_templates::tests::resolve_templates_path_defaults_to_prod_bundle ... ok
test commands::channel_templates::tests::resolve_templates_path_honors_override ... ok
test commands::channels::tests::archive_filter_all_instances_archived_is_skipped_with_explicit_reason ... ok
test commands::channels::tests::archive_filter_drops_archived_instance_and_resolves_to_live_one ... ok
test commands::channels::tests::archive_filter_no_instances_ever_existed_is_skipped_with_different_reason ... ok
test commands::channels::tests::archive_filter_report_shape_includes_archived_excluded ... ok
test commands::channels::tests::archive_filter_state3_ambiguity_warns_on_sink_and_in_error_detail ... ok
test commands::channels::tests::archive_filter_state3_on_ambiguity_path_keeps_hard_error_with_warning ... ok
test commands::channels::tests::archive_filter_state3_on_success_path_proceeds_with_warning ... ok
test commands::channels::tests::archive_filter_state3_success_warns_on_sink_and_in_report ... ok
test commands::channels::tests::build_template_report_omits_warning_key_when_none ... ok
test commands::channels::tests::cardinality_empty_roster_resolves_to_empty_lists ... ok
test commands::channels::tests::cardinality_ignores_instances_for_unrelated_slugs ... ok
test commands::channels::tests::cardinality_mixed_slugs_zero_one_many_reports_first_ambiguity ... ok
test commands::channels::tests::cardinality_multiple_instances_is_hard_error_listing_candidates ... ok
test commands::channels::tests::cardinality_one_instance_is_added ... ok
test commands::channels::tests::cardinality_zero_instances_is_skipped_not_error ... ok
test commands::channels::tests::from_event_extracts_known_tags ... ok
test commands::channels::tests::from_event_marks_archived ... ok
test commands::channels::tests::from_event_marks_private ... ok
test commands::channels::tests::from_event_returns_none_without_required_tags ... ok
test commands::channels::tests::from_event_tolerates_malformed_tags ... ok
test commands::channels::tests::name_matches_exact_case_insensitive ... ok
test commands::channels::tests::name_matches_substring_case_insensitive ... ok
test commands::channels::tests::set_add_policy_accepts_allowed_policy ... ok
test commands::channels::tests::set_add_policy_env_gate_rejects_disallowed_via_full_path ... ok
test commands::channels::tests::set_add_policy_no_restriction_allows_all ... ok
test commands::channels::tests::set_add_policy_rejects_disallowed_policy ... ok
test commands::channels::tests::validate_ttl_accepts_positive ... ok
test commands::channels::tests::validate_ttl_rejects_overflow ... ok
test commands::channels::tests::validate_ttl_rejects_zero_and_negative ... ok
test commands::emoji::tests::union_equal_timestamps_tie_break_to_smallest_url ... ok
test commands::emoji::tests::union_latest_set_wins_per_shortcode ... ok
test commands::import::export::tests::file_label_and_link ... ok
test commands::import::export::tests::importable_filters_system_subtypes ... ok
test commands::import::export::tests::loads_fixture_export_directory ... ok
test commands::import::export::tests::merges_slackdump_public_private_dm_and_mpim_roots ... ok
test commands::import::export::tests::rejects_conflicting_duplicate_conversation_names ... ok
test commands::import::export::tests::rejects_conflicting_duplicate_message_timestamps ... ok
test commands::import::export::tests::rejects_unsafe_conversation_directory_names ... ok
test commands::import::export::tests::slack_timestamps_sort_exactly ... ok
test commands::import::export::tests::split_private_root_may_omit_channels_json ... ok
test commands::import::export::tests::ts_seconds_parses_whole_part ... ok
test commands::import::export::tests::user_best_name_falls_back ... ok
test commands::import::mapping::tests::csv_parser_accepts_leading_space_before_quotes_and_bare_cr_records ... ok
test commands::import::mapping::tests::csv_parser_handles_generated_crosswalk_shape ... ok
test commands::import::mapping::tests::rejects_duplicate_slack_id_before_target_reuse_diagnostics ... ok
test commands::import::mapping::tests::rejects_duplicate_target_uuid ... ok
test commands::import::mrkdwn::tests::converts_bold_conservatively ... ok
test commands::import::mrkdwn::tests::converts_channels_and_specials ... ok
test commands::import::mrkdwn::tests::converts_links ... ok
test commands::import::mrkdwn::tests::converts_user_mentions ... ok
test commands::import::mrkdwn::tests::keeps_unclosed_angle_verbatim ... ok
test commands::import::mrkdwn::tests::preserves_code ... ok
test commands::import::mrkdwn::tests::unescapes_entities ... ok
test commands::import::state::tests::missing_file_is_empty_state ... ok
test commands::import::state::tests::rejects_non_empty_legacy_state ... ok
test commands::import::state::tests::rejects_state_from_a_different_workspace ... ok
test commands::import::state::tests::roundtrips_through_disk ... ok
test commands::import::state::tests::upgrades_v2_importer_channels_as_already_prepared ... ok
test commands::import::tests::active_channel_unarchive_rejection_is_the_only_accepted_no_op ... ok
test commands::import::tests::attachment_only_message_retains_visible_card_content ... ok
test commands::import::tests::author_resolution ... ok
test commands::import::tests::channel_uuid_is_deterministic_and_team_scoped ... ok
test commands::import::tests::dry_run_is_offline_and_reports_counts ... ok
test commands::import::tests::emoji_mapping ... ok
test commands::import::tests::existing_channel_map_is_validated_and_seeded_as_unprepared ... ok
test commands::import::tests::identity_map_parses_npub_and_hex_and_rejects_nsec ... ok
test commands::import::tests::imported_message_escapes_markdown_in_the_author_prefix ... ok
test commands::import::tests::imported_message_keeps_routing_and_provenance_tags ... ok
test commands::import::tests::imported_thread_broadcast_remains_visible_in_the_channel_timeline ... ok
test commands::import::tests::native_dm_open_requires_the_exact_mapped_participant_set ... ok
test commands::import::tests::native_dm_response_payload_extracts_relay_channel_id ... ok
test commands::import::tests::pending_reactions_include_resumable_work_and_dedupe_aliases ... ok
test commands::import::tests::provenance_tags_shape ... ok
test commands::import::tests::resumed_dm_cannot_reuse_another_slack_conversations_buzz_uuid ... ok
test commands::import::tests::rich_text_blocks_render_lists_mentions_links_and_styles ... ok
test commands::import::tests::separate_slack_dms_cannot_collapse_into_one_buzz_participant_set ... ok
test commands::import::tests::thread_root_key_only_for_replies ... ok
test commands::mem::tests::diffy_apply_refuses_mismatched_context ... ok
test commands::mem::tests::diffy_apply_succeeds_on_exact_context ... ok
test commands::mem::tests::diffy_roundtrip_preserves_content ... ok
test commands::mem::tests::multi_file_header_count ... ok
test commands::mem::tests::resolve_reader_agent_flag_uses_cli_identity_as_owner ... ok
test commands::mem::tests::resolve_reader_defaults_to_agent_identity ... ok
test commands::mem::tests::resolve_reader_rejects_agent_flag_matching_cli_identity ... ok
test commands::mem::tests::resolve_reader_rejects_owner_with_agent_flag ... ok
test commands::mem::tests::sha256_hex_abc ... ok
test commands::mem::tests::sha256_hex_empty ... ok
test commands::mem::tests::sha256_hex_handles_newline_terminated_value ... ok
test commands::mem::tests::strict_position_accepts_exact_match ... ok
test commands::mem::tests::strict_position_accepts_multi_hunk_against_original ... ok
test commands::mem::tests::strict_position_accepts_pure_insertion_into_empty ... ok
test commands::mem::tests::strict_position_handles_no_trailing_newline ... ok
test commands::mem::tests::strict_position_rejects_offset_slide ... ok
test commands::messages::tests::author_name_ambiguity_returns_all_candidates ... ok
test commands::messages::tests::author_name_dedups_replaceable_event_copies ... ok
test commands::messages::tests::author_name_match_is_exact_case_insensitive ... ok
test commands::messages::tests::author_name_no_match_and_malformed_content ... ok
test commands::messages::tests::cli_pipeline_resolves_body_at_names_to_member_pubkeys ... ok
test commands::messages::tests::cli_pipeline_resolves_multiword_display_names ... ok
test commands::messages::tests::cli_pipeline_returns_empty_when_no_at_names ... ok
test commands::messages::tests::malformed_marker_id_is_ignored ... ok
test commands::messages::tests::malformed_root_does_not_shadow_valid_reply ... ok
test commands::messages::tests::malformed_tags_are_skipped ... ok
test commands::messages::tests::no_thread_markers_returns_none ... ok
test commands::messages::tests::non_array_input_returns_none ... ok
test commands::messages::tests::parse_member_pubkeys_filters_invalid_hex ... ok
test commands::messages::tests::parse_member_pubkeys_handles_malformed_event ... ok
test commands::messages::tests::parse_member_pubkeys_ignores_non_p_tags ... ok
test commands::messages::tests::reply_only_falls_back_to_reply_target ... ok
test commands::messages::tests::root_marker_wins_over_reply_marker ... ok
test commands::messages::tests::unmarked_e_tag_ignored ... ok
test commands::notes::tests::format_note_candidates_sorts_newest_first ... ok
test commands::notes::tests::format_note_candidates_uses_untitled_for_empty_title ... ok
test commands::notes::tests::note_snapshot_garbage_published_at_yields_none ... ok
test commands::notes::tests::note_snapshot_missing_d_tag_is_err ... ok
test commands::notes::tests::note_snapshot_parses_all_standard_tags ... ok
test commands::notes::tests::note_snapshot_rejects_wrong_kind ... ok
test commands::notes::tests::parse_naddr_accepts_kpi_form ... ok
test commands::notes::tests::parse_naddr_rejects_wrong_kind ... ok
test commands::notes::tests::parse_slug_accepts_dco_recipe ... ok
test commands::notes::tests::parse_slug_accepts_dots_and_underscores ... ok
test commands::notes::tests::parse_slug_rejects_empty ... ok
test commands::notes::tests::parse_slug_rejects_overlong ... ok
test commands::notes::tests::parse_slug_rejects_spaces ... ok
test commands::notes::tests::parse_slug_rejects_uppercase ... ok
test commands::notes::tests::rm_event_is_kind5_with_a_tag_only ... ok
test commands::notes::tests::set_first_publish_requires_title ... ok
test commands::notes::tests::set_first_publish_sets_published_at_to_now ... ok
test commands::notes::tests::set_first_publish_with_no_prior_published_at_uses_now_even_after_a_garbage_prior ... ok
test commands::notes::tests::set_update_carries_summary_when_omitted ... ok
test commands::notes::tests::set_update_carries_tags_when_omitted ... ok
test commands::notes::tests::set_update_clears_summary_when_explicit_empty ... ok
test commands::notes::tests::set_update_clears_tags_when_explicit_empty_slice ... ok
test commands::notes::tests::set_update_clears_title_when_explicit_empty ... ok
test commands::notes::tests::set_update_preserves_published_at_and_carries_title ... ok
test commands::notes::tests::set_update_replaces_tags_when_provided ... ok
test commands::notes::tests::validate_get_args_accepts_minimal_forms ... ok
test commands::notes::tests::validate_get_args_rejects_author_and_latest_together ... ok
test commands::notes::tests::validate_get_args_rejects_naddr_with_refiners ... ok
test commands::notes::tests::validate_get_args_requires_exactly_one_selector ... ok
test commands::patches::tests::parse_committer_rejects_wrong_field_count ... ok
test commands::patches::tests::parse_committer_valid ... ok
test commands::patches::tests::parse_status_accepts_known_words ... ok
test commands::patches::tests::parse_status_rejects_unknown_word ... ok
test commands::pr::tests::read_optional_body_defaults_empty ... ok
test commands::pr::tests::read_optional_body_rejects_body_and_body_file_together ... ok
test commands::repos::tests::duplicate_write_response_is_a_conflict ... ok
test commands::repos::tests::protection_list_keeps_unknown_rules_visible ... ok
test commands::repos::tests::protection_list_surfaces_malformed_rules_for_recovery ... ok
test commands::repos::tests::protection_remove_preserves_other_patterns ... ok
test commands::repos::tests::protection_set_requires_at_least_one_rule ... ok
test commands::repos::tests::protection_update_enforces_repository_rule_limit ... ok
test commands::repos::tests::protection_update_preserves_metadata_and_replaces_only_matching_pattern ... ok
test commands::repos::tests::protection_update_rejects_malformed_existing_rules ... ok
test commands::repos::tests::successful_write_response_is_normalized ... ok
test commands::social::tests::malformed_tags_json_is_usage_error ... ok
test commands::social::tests::parameterized_social_list_kind_detection ... ok
test commands::social::tests::parses_tags_json_and_detects_d_tag ... ok
test commands::social::tests::social_list_kind_validation_accepts_supported_kinds ... ok
test commands::social::tests::social_list_kind_validation_rejects_unsupported_kinds ... ok
test commands::users::tests::presence_subject_falls_back_to_author_for_malformed_p_tag ... ok
test commands::users::tests::presence_subject_falls_back_to_author_without_p_tag ... ok
test commands::users::tests::presence_subject_uses_p_tag ... ok
test error::tests::json_error_includes_retryable_field_for_network ... ok
test error::tests::json_error_retryable_false_for_usage ... ok
test error::tests::network_builder_errors_are_not_retryable ... ok
test error::tests::network_display_includes_detail_beyond_prefix ... ok
test error::tests::other_errors_are_not_retryable ... ok
test error::tests::relay_400_401_403_404_422_are_not_retryable ... ok
test error::tests::relay_429_502_503_504_are_retryable ... ok
test tests::cli_definition_is_valid ... ok
test tests::command_inventory_is_stable ... ok
test tests::subcommand_counts_are_stable ... ok
test tests::subcommand_names_are_stable ... ok
test validate::tests::infer_language_no_ext ... ok
test validate::tests::infer_language_path_with_dirs ... ok
test validate::tests::infer_language_rust ... ok
test validate::tests::infer_language_ts ... ok
test validate::tests::infer_language_tsx ... ok
test validate::tests::infer_language_unknown_ext ... ok
test validate::tests::parse_event_id_invalid ... ok
test validate::tests::parse_event_id_valid ... ok
test validate::tests::parse_uuid_invalid ... ok
test validate::tests::parse_uuid_valid ... ok
test validate::tests::percent_encode_empty ... ok
test validate::tests::percent_encode_slash ... ok
test validate::tests::percent_encode_space ... ok
test validate::tests::percent_encode_unicode_multibyte ... ok
test validate::tests::percent_encode_unreserved_unchanged ... ok
test validate::tests::read_file_or_stdin_does_not_treat_path_as_literal_content ... ok
test validate::tests::read_file_or_stdin_reads_file_contents ... ok
test validate::tests::read_or_stdin_passthrough_empty_string ... ok
test validate::tests::read_or_stdin_passthrough_returns_value ... ok
test validate::tests::truncate_diff_appends_notice ... ok
test validate::tests::truncate_diff_at_limit_noop ... ok
test validate::tests::truncate_diff_cuts_at_hunk_boundary ... ok
test validate::tests::truncate_diff_falls_back_to_newline ... ok
test validate::tests::truncate_diff_under_limit_noop ... ok
test validate::tests::validate_content_size_at_limit ... ok
test validate::tests::validate_content_size_empty ... ok
test validate::tests::validate_content_size_over_limit ... ok
test validate::tests::validate_hex64_all_digits ... ok
test validate::tests::validate_hex64_non_hex_char ... ok
test validate::tests::validate_hex64_too_long ... ok
test validate::tests::validate_hex64_too_short ... ok
test validate::tests::validate_hex64_valid ... ok
test validate::tests::validate_repo_id_boundary_64_chars ... ok
test validate::tests::validate_repo_id_rejects_double_dot ... ok
test validate::tests::validate_repo_id_rejects_empty ... ok
test validate::tests::validate_repo_id_rejects_invalid_chars ... ok
test validate::tests::validate_repo_id_rejects_leading_dot ... ok
test validate::tests::validate_repo_id_rejects_over_64 ... ok
test validate::tests::validate_repo_id_valid ... ok
test validate::tests::validate_uuid_empty ... ok
test validate::tests::validate_uuid_malformed ... ok
test validate::tests::validate_uuid_valid ... ok
test client::retry_policy_tests::upload_body_loss_is_retried_with_same_file_bytes ... ok
test client::retry_policy_tests::exhausted_connect_failures_return_network_retryable ... ok
test client::retry_policy_tests::stored_event_all_body_losses_return_delivery_unknown ... ok
test client::retry_policy_tests::stored_event_body_loss_is_retried_with_same_event_bytes ... ok
test client::retry_policy_tests::with_retry_body_retries_on_body_transfer_failure ... ok
test client::retry_policy_tests::query_429_with_hint_is_retried ... ok
test client::retry_policy_tests::moderation_kind_ingest_429_is_retried_until_success ... ok

test result: ok. 296 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.02s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 1 test
test crates/buzz-cli/src/lib.rs - run_from_args (line 19) ... ignored

test result: ok. 0 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s: 296 passed\n- : passed\n- : passed

@RenKoya1

RenKoya1 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Re-reviewed 63857277. Both blockers are properly fixed — ensure_dm_uuid_unclaimed runs on the resume path and before the ledger insert on the fresh path, which also catches a poisoned state file written by the earlier version, and that's better than what I asked for. build_dm_open gives you the SDK's pubkey validation and length guard back. 296 tests, fmt and clippy clean.

Two things came out of the fixes.

The author escape breaks the desktop prefix strip. This one's on me for not checking the consumer when I suggested it.

desktop/src/features/messages/lib/formatTimelineMessages.ts:108-109 builds the prefix from the raw import_author tag value:

const prefix = `**${importAuthor.displayName}**: `;
return body.startsWith(prefix) ? body.slice(prefix.length) : body;

provenance_tags still writes the unescaped name into the tag while the body now carries the escaped one, so I ran it against the real function:

✖ SCRATCH: escaped author prefix vs raw import_author tag
  AssertionError: prefix should be stripped
    actual:   '**A\*B**: hello from slack'
    expected: 'hello from slack'

The prefix ends up rendering as visible duplicate text on every message that person wrote, which is worse than the mangled bold it was fixing. Mirroring the escape in stripImportAuthorPrefix is the smallest fix, and formatTimelineMessages.test.mjs:100 is already the right place for a case.

is_already_unarchived can't fire, though not for the reason you'd expect. The string does exist — crates/buzz-db/src/channel.rs:1374 returns DbError::AccessDenied("channel is not archived") when archived_at is null, so the matching itself is fine.

The problem is that the error never leaves the relay. handle_side_effects is best-effort: its error is warn!-logged and discarded, and ingest returns accepted: true regardless (crates/buzz-relay/src/handlers/ingest.rs:2640-2646; 9002 is a side-effect kind via 9000..=9022). So submit gets a success either way and the tolerance branch is unreachable. Your test asserts your own string matching, so it stays green while proving nothing about the relay.

The part actually worth fixing is the other half of that comment — "every other failure must stop the import before writes" doesn't hold either. If the unarchive fails for a real reason on a genuinely archived channel you still get accepted: true, the import proceeds, and every message is rejected with "invalid: channel is archived" (ingest.rs:2140-2145) until MAX_CONSECUTIVE_FAILURES trips. That's the same confusing abort the fix was meant to eliminate, just relocated.

Since a 9002 ack tells you nothing, the only way to know is to look: read the channel back after the unarchive and bail if it's still archived. Or leave the behaviour and drop the claim from the comment, because a comment describing a guarantee the relay doesn't make is worse than no comment. I don't think this blocks either way.

One thing I checked because it would have been genuinely bad: the relay matches metadata tags individually (side_effects.rs:1573), so a 9002 carrying only h + archived does not wipe name/about/visibility on an adopted channel. That's safe.

Smaller notes: channels.json optional, the crosswalk duplicate messages, and the CSV whitespace/bare-CR handling all look right, and the leading-whitespace-then-quote case is handled the way I'd want. ensure_dm_uuid_unclaimed fires after the DM-open event has already gone to the relay, so a collision still leaves that event persisted and re-runs publish_dm_visibility_snapshot — no history is written, which is the property that matters, just worth knowing.

Fix the desktop prefix and this is good to go from my side.

Co-authored-by: nicknack5050 <nick@lucid.rocks>
Signed-off-by: nicknack5050 <nick@lucid.rocks>
@nicknack5050

Copy link
Copy Markdown
Author

Fixed every known review follow-up in 08d191d2f2fbe92f418cf2535bf16b65115f9766.

  • Desktop now strips both legacy raw and importer-escaped author prefixes; the regression covers Slack display names containing Markdown metacharacters.
  • Adopted-channel unarchive now reads the current kind 39000 metadata back from the relay and fails closed if the channel is still archived, missing, or invalid.
  • The importer is split into focused channel-state, DM, model, render, and report modules; every importer source file is now below 1,000 lines.

Exact-commit validation:

  • bin/just ci passed with the same 08d191d2f2fbe92f418cf2535bf16b65115f9766 HEAD before and after.
  • buzz-cli: 296 tests, strict all-target Clippy, and formatting passed.
  • Desktop: 3,739 tests plus check/typecheck and production build passed.
  • Tauri: 1,820 tests plus 3 diagnostic tests passed; 14 declared OS-keychain/relay tests remained ignored.
  • Web check/build and mobile format/analyze passed; mobile tests finished with 824 passed and 1 declared skip.

The remote PR head matches the verified commit. No Slack or Buzz history was written during validation.

@RenKoya1
RenKoya1 merged commit 79d31fa into RenKoya1:feat/slack-import Jul 30, 2026
1 check passed
RenKoya1 pushed a commit that referenced this pull request Jul 31, 2026
…3813)

## What

Clearing an edit to empty and hitting accept now **deletes the message**
instead of hanging. One of Sam's frequent workflows is to delete a
message by editing it, clearing the text, and pressing Enter — which
previously no-op'd (a deliberate guard blocked empty edits).

## How

Pure client-side wiring — **no relay, schema, or Rust changes.**

1. **`MessageComposer.tsx`** — the edit path had a guard that *blocked*
empty edits (`if (!trimmed && !hasMedia) return;`). That guard is simply
**removed**, so empty content flows through the normal edit path to
`onEditSave("", [], [])`. `buildOutgoingMessage("")` is a safe no-op.
2. **`handleEditSave` in `useChannelPaneHandlers.ts`** — when an edit is
submitted with empty text and no media tags, it exits edit mode and
opens the **same "Delete message?" confirmation** the Delete menu action
shows, rather than publishing an empty edit.
3. **`DeleteMessageConfirmDialog.tsx`** — the confirmation dialog,
extracted into **one shared component**. `MessageActionBar` renders it
for the Delete menu action (previously inline), and `ChannelScreen`
renders it for the empty-edit path. No duplicated dialog UI. **Delete**
runs the existing `deleteMutate`; **Cancel** leaves the message
untouched.

Because both the main timeline and the thread panel already route
edit-save through `handleEditSave`, this covers both surfaces with a
single dialog at the `ChannelScreen` level — no per-composer plumbing.

- Image-only edits (empty text but attachments present) still publish
normally — only a *fully* empty edit prompts to delete.
- An empty edit can never publish an empty body: `handleEditSave`
returns before the edit mutation.

## Review history

This PR was reworked three times in response to review — each pass made
it smaller:

1. First cut wrapped this in a new "Delete message?" `AlertDialog`
rendered from a composer hook — a verbatim duplicate of the confirmation
already in `MessageActionBar.tsx`. Removed.
2. Second cut threaded a dedicated `onDeleteEditTarget` callback down
`ChannelScreen → ChannelPane → MessageComposer / MessageThreadPanel`.
Also redundant — the delete decision moved entirely into
`handleEditSave`, which every edit-save already flows through.
3. Third cut added a special-case empty branch to the composer, which
pushed `MessageComposer.tsx` over the file-size ratchet and led to an
unrelated emoji-helper extraction to make room. Both gone: deleting the
pre-existing guard (rather than adding a branch) is net-negative, so
there's no ratchet pressure and **nothing emoji-related in this PR**.
`MessageComposer.types.ts` is back to baseline too.
4. Fourth pass (this one): an unconfirmed, no-undo delete was too sharp.
The empty-edit path now routes through the same **"Delete message?"
confirmation** as the menu action — shared as one
`DeleteMessageConfirmDialog` component (so it's reuse, not the duplicate
dialog from cut #1).

## Testing

- **E2E:** `desktop/tests/e2e/empty-edit-delete.spec.ts` (Playwright,
smoke project), three tests, all passing locally:
- *clearing an edit to empty prompts to delete, then deletes on confirm*
— edits the mock identity's own `#general` message, clears it, Enter →
the **"Delete message?"** dialog appears; Delete → the row disappears
and edit mode exits.
- *cancelling the empty-edit delete keeps the message* — same up to the
dialog, then Cancel → the message survives.
- *a non-empty edit still edits and never deletes* — guards the other
direction (no dialog).
- `pnpm typecheck`, biome, file-size + px-text guards all clean; full
desktop unit suite (3847 tests) passing locally.

> Heads-up for the reviewer: pushed with `--no-verify` because the
pre-push hook runs the Rust **integration** suite, which needs Docker
(Postgres/Redis) that isn't available in this environment — it doesn't
apply to this desktop-only change. CI runs the real gates.

---

🐝 Built by Bumble in Buzz, from a conversation in #test-swesterman.

---------

Signed-off-by: Sam Westerman <swesterman@squareup.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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